diff --git a/.dockerignore b/.dockerignore
new file mode 120000
index 000000000000..3e4e48b0b5fe
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1 @@
+.gitignore
\ No newline at end of file
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 000000000000..fc3abadc1d0d
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,17 @@
+# see https://EditorConfig.org
+
+root = true
+
+[*]
+indent_style = space
+trim_trailing_whitespace = true
+end_of_line = lf
+charset = utf-8
+
+[*.py]
+indent_size = 4
+insert_final_newline = true
+
+[*.sh]
+indent_size = 4
+insert_final_newline = true
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000000..34be60856ad9
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,12 @@
+# Auto detect text files and perform end-of-line normalization (to LF)
+* text=auto
+
+# These Windows files should have CRLF line endings in checkout
+*.bat text eol=crlf
+*.ps1 text eol=crlf
+
+# Never perform LF normalization on these files
+*.ico binary
+*.jar binary
+*.png binary
+*.zip binary
diff --git a/.github/ISSUE_TEMPLATE/01_issue.yml b/.github/ISSUE_TEMPLATE/01_issue.yml
new file mode 100644
index 000000000000..7a3f6cb4b206
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/01_issue.yml
@@ -0,0 +1,31 @@
+name: Issue
+description: Submit a new issue.
+#labels: [bug]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ ## Read this first!
+
+ * This issue tracker is for bug reports and development, not general questions.
+ * There is no private support! Scammers will reply to you here and tell you to go to their external website or contact them privately in email/telegram/whatsapp/etc.
+ They will try to **steal** your coins!
+ Be extremely suspicious of anyone offering help in a non-public way. Scammers will want to chat with you in private as then other people will not get a chance to point out the scam.
+ * Do not post issues about non-**Bitcoin** versions of Electrum.
+
+ ----
+ #- type: checkboxes
+ # attributes:
+ # label: Is there an existing issue for this already?
+ # #description: Please search to see if this issue is already being tracked.
+ # options:
+ # - label: I have searched the existing issues
+ # required: true
+ - type: textarea
+ id: main-text-body
+ attributes:
+ label: Description
+ #description: Tell us what went wrong
+ placeholder: Please search existing issues for duplicates.
+ validations:
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 000000000000..ffcde8c01b99
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+blank_issues_enabled: false
+contact_links:
+# - name: Electrum Security Policy
+# url: https://github.com/spesmilo/electrum/blob/master/SECURITY.md
+# about: View security policy
+ - name: Community Forum
+ url: https://bitcointalk.org/index.php?board=98.0
+ about: Ask non-development-related questions here
diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml
new file mode 100644
index 000000000000..41d73a8bec3e
--- /dev/null
+++ b/.github/workflows/builds.yml
@@ -0,0 +1,203 @@
+name: builds
+
+on:
+ schedule:
+ - cron: '30 2 * * *' # 02:30 UTC daily
+ workflow_dispatch:
+ inputs:
+ target:
+ description: 'Which build(s) to run'
+ required: true
+ default: 'all'
+ type: choice
+ options: [all, windows, android, appimage, tarball]
+ android_arch:
+ description: 'Android architecture (when running android)'
+ required: false
+ default: 'arm64-v8a'
+ type: choice
+ options: [arm64-v8a, armeabi-v7a, x86_64]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: builds-${{ github.ref }}
+ cancel-in-progress: false # never kill a nightly or in-progress build
+
+env:
+ ARTIFACT_RETENTION: '14'
+
+jobs:
+ windows:
+ name: "build: Windows"
+ if: ${{ github.event_name != 'workflow_dispatch' || inputs.target == 'all' || inputs.target == 'windows' }}
+ runs-on: ubuntu-24.04
+ timeout-minutes: 90
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 0 # for git describe / version detection
+
+ - name: Cache Wine pip
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: contrib/build-wine/.cache/win*/wine_pip_cache
+ key: wine-pip-${{ hashFiles('contrib/deterministic-build/*.txt', 'contrib/build-wine/**') }}
+
+ - name: Cache Wine native lib builds (libsecp256k1, libusb, libzbar)
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: contrib/build-wine/.cache/win*/build
+ key: wine-build-${{ hashFiles('contrib/make_libsecp256k1.sh', 'contrib/make_libusb.sh', 'contrib/make_zbar.sh', 'contrib/build-wine/**') }}
+
+ - name: Build Windows binaries
+ run: ./contrib/build-wine/build.sh
+
+ - name: List output
+ run: ls -lah contrib/build-wine/dist/
+
+ - name: Upload Windows artifacts
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: electrum-windows-${{ github.sha }}
+ path: contrib/build-wine/dist/*
+ retention-days: ${{ env.ARTIFACT_RETENTION }}
+ if-no-files-found: error
+ compression-level: 0
+
+ android:
+ name: "build: Android (${{ inputs.android_arch || 'arm64-v8a' }})"
+ if: ${{ github.event_name != 'workflow_dispatch' || inputs.target == 'all' || inputs.target == 'android' }}
+ runs-on: ubuntu-24.04
+ timeout-minutes: 180
+ env:
+ APK_ARCH: ${{ inputs.android_arch || 'arm64-v8a' }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 0
+
+ # GitHub guarantees 14 GB of free space, but we can delete some things for more space.
+ - name: Free disk space
+ run: |
+ df -h
+ sudo rm -rf \
+ /usr/share/dotnet \
+ /usr/share/swift \
+ /usr/local/lib/android \
+ /usr/local/.ghcup \
+ /usr/local/share/powershell \
+ /opt/ghc \
+ /opt/hostedtoolcache/CodeQL \
+ /opt/microsoft \
+ /opt/pipx
+ sudo docker image prune --all --force || true
+ df -h
+
+ - name: Cache python packages
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: packages
+ key: android-packages-${{ env.APK_ARCH }}-${{ hashFiles('contrib/deterministic-build/**', 'contrib/make_packages.sh', 'contrib/android/**', '!contrib/android/.cache') }}
+
+ - name: Cache buildozer (p4a)
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: |
+ .buildozer_qml/android/platform/build-${{ env.APK_ARCH }}/packages
+ .buildozer_qml/android/platform/build-${{ env.APK_ARCH }}/build
+ key: android-buildozer-${{ env.APK_ARCH }}-${{ hashFiles('contrib/android/**', '!contrib/android/.cache') }}
+
+ - name: Build APK (debug)
+ run: ./contrib/android/build.sh qml "$APK_ARCH" debug
+
+ - name: List output
+ run: ls -lah dist/
+
+ - name: Upload Android APK
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: electrum-android-${{ env.APK_ARCH }}-${{ github.sha }}
+ path: dist/*
+ retention-days: ${{ env.ARTIFACT_RETENTION }}
+ if-no-files-found: error
+ compression-level: 0
+
+ appimage:
+ name: "build: AppImage"
+ if: ${{ github.event_name != 'workflow_dispatch' || inputs.target == 'all' || inputs.target == 'appimage' }}
+ runs-on: ubuntu-24.04
+ timeout-minutes: 90
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 0
+
+ - name: Cache AppImage pip
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: contrib/build-linux/appimage/.cache/pip_cache
+ key: appimage-pip-${{ hashFiles('contrib/deterministic-build/*.txt', 'contrib/build-linux/appimage/**') }}
+
+ - name: Cache AppImage build
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: contrib/build-linux/appimage/.cache/appimage
+ key: appimage-build-${{ hashFiles('contrib/make_libsecp256k1.sh', 'contrib/make_zbar.sh', 'contrib/build-linux/appimage/**') }}
+
+ - name: Build AppImage
+ run: ./contrib/build-linux/appimage/build.sh
+
+ - name: List output
+ run: ls -lah dist/
+
+ - name: Upload AppImage
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: electrum-appimage-${{ github.sha }}
+ path: dist/*.AppImage
+ retention-days: ${{ env.ARTIFACT_RETENTION }}
+ if-no-files-found: error
+ compression-level: 0
+
+ tarball:
+ name: "build: tarball (${{ matrix.variant }})"
+ if: ${{ github.event_name != 'workflow_dispatch' || inputs.target == 'all' || inputs.target == 'tarball' }}
+ runs-on: ubuntu-24.04
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - variant: full
+ omit_unclean: '0'
+ artifact_name: electrum-tarball
+ - variant: source-only
+ omit_unclean: '1'
+ artifact_name: electrum-sourceonly-tarball
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 0
+
+ - name: Build source tarball (${{ matrix.variant }})
+ env:
+ OMIT_UNCLEAN_FILES: ${{ matrix.omit_unclean }}
+ run: ./contrib/build-linux/sdist/build.sh
+
+ - name: List output
+ run: ls -lah dist/
+
+ - name: Upload tarball
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: ${{ matrix.artifact_name }}-${{ github.sha }}
+ path: dist/*.tar.gz
+ retention-days: ${{ env.ARTIFACT_RETENTION }}
+ if-no-files-found: error
+ compression-level: 0
diff --git a/.github/workflows/locale.yml b/.github/workflows/locale.yml
new file mode 100644
index 000000000000..273006b7e12f
--- /dev/null
+++ b/.github/workflows/locale.yml
@@ -0,0 +1,48 @@
+name: locale
+
+on:
+ push:
+ branches: [master]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ push-locale:
+ name: "locale: upload to crowdin"
+ runs-on: ubuntu-24.04
+ steps:
+ - name: Checkout (with submodules)
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ submodules: true
+ fetch-depth: 0
+
+ - name: Setup Python
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ with:
+ python-version: "3.10"
+ cache: 'pip'
+ cache-dependency-path: contrib/requirements/requirements-ci.txt
+
+ - name: Install OS deps
+ run: |
+ sudo apt-get update
+ sudo apt-get -y install gettext qt6-l10n-tools
+
+ - name: Install Python deps
+ run: |
+ pip install -r contrib/requirements/requirements-ci.txt
+ pip install requests
+
+ - name: Push locale to Crowdin
+ # CROWDIN_API_KEY needs to be set in GitHub repository settings
+ # - api key is for crowdin account: "SomberNight_CI_BOT"
+ # ref https://crowdin.com/settings#api-key
+ # scope:
+ # - Projects/"Source files & strings" - read and write
+ # - Projects/"Translations" - read and write
+ env:
+ crowdin_api_key: ${{ secrets.CROWDIN_API_KEY }}
+ run: ./contrib/locale/push_locale.py
diff --git a/.github/workflows/regtest.yml b/.github/workflows/regtest.yml
new file mode 100644
index 000000000000..5e88c488d9af
--- /dev/null
+++ b/.github/workflows/regtest.yml
@@ -0,0 +1,113 @@
+name: regtest
+
+on:
+ push:
+ branches: [master]
+ tags: ['*']
+ pull_request:
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
+
+permissions:
+ contents: read
+
+jobs:
+ regtest:
+ name: "Regtest functional tests"
+ runs-on: ubuntu-24.04
+ env:
+ LD_LIBRARY_PATH: contrib/_saved_secp256k1_build/
+ ELECTRUM_ECC_DONT_COMPILE: "1" # we build manually to make caching it easier
+ PIP_BREAK_SYSTEM_PACKAGES: "1"
+ # ElectrumX exits with an error without this:
+ ALLOW_ROOT: "1"
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 0
+ - name: Set up Python
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ with:
+ python-version: "3.10"
+ cache: 'pip'
+ cache-dependency-path: contrib/requirements/requirements.txt
+ - name: Determine latest bitcoind version
+ id: bitcoind-version
+ run: |
+ BITCOIND_VERSION=$(curl https://bitcoincore.org/en/download/ | grep -E -i --only-matching 'Latest version: [0-9\.]+' | grep -E --only-matching '[0-9\.]+')
+ if [ -z "$BITCOIND_VERSION" ]; then
+ echo "Failed to detect bitcoind version from bitcoincore.org" >&2
+ exit 1
+ fi
+ echo "Detected bitcoind version: $BITCOIND_VERSION"
+ echo "version=$BITCOIND_VERSION" >> "$GITHUB_OUTPUT"
+ - name: Cache bitcoind binary
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: /tmp/bitcoind
+ key: bitcoind-ubuntu-24.04-${{ steps.bitcoind-version.outputs.version }}
+ - name: Install OS deps
+ run: |
+ sudo apt-get update
+ sudo apt-get -y install curl jq bc automake libtool
+ - name: Install Electrum and ElectrumX
+ # installs e-x some commits after 1.18.0 tag
+ # uses --ignore-installed to ignore installed system installed attrs
+ run: |
+ python3 -m pip install --user --upgrade pip
+ python3 -m pip install --user .[tests] --ignore-installed
+ python3 -m pip install --user git+https://github.com/spesmilo/electrumx.git@0b260d4345242cc41e316e97d7de10ae472fd172
+ - name: Fetch bitcoind binary
+ env:
+ BITCOIND_VERSION: ${{ steps.bitcoind-version.outputs.version }}
+ run: |
+ mkdir -p /tmp/bitcoind
+ BITCOIND_FILENAME=bitcoin-$BITCOIND_VERSION-x86_64-linux-gnu.tar.gz
+ BITCOIND_PATH=/tmp/bitcoind/$BITCOIND_FILENAME
+ BITCOIND_URL=https://bitcoincore.org/bin/bitcoin-core-$BITCOIND_VERSION/$BITCOIND_FILENAME
+ cd /tmp/bitcoind
+ tar -xaf $BITCOIND_PATH || (rm -f /tmp/bitcoind/* && curl --output $BITCOIND_PATH $BITCOIND_URL && tar -xaf $BITCOIND_PATH)
+ sudo cp -a bitcoin-$BITCOIND_VERSION/* /usr/
+ - name: Cache libsecp256k1 build
+ id: cache-libsecp
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: contrib/_saved_secp256k1_build
+ key: libsecp-${{ runner.os }}-${{ hashFiles('contrib/make_libsecp256k1.sh') }}
+ - name: Build libsecp256k1
+ if: steps.cache-libsecp.outputs.cache-hit != 'true'
+ run: |
+ ./contrib/make_libsecp256k1.sh
+ mkdir -p contrib/_saved_secp256k1_build
+ cp electrum/libsecp256k1.so.* contrib/_saved_secp256k1_build/
+ - name: Start bitcoind (regtest)
+ run: nohup tests/regtest/run_bitcoind.sh > /tmp/bitcoind.log 2>&1 &
+ - name: Start electrumx
+ run: nohup tests/regtest/run_electrumx.sh > /tmp/electrumx.log 2>&1 &
+ - name: Run regtest tests
+ run: |
+ sleep 10
+ python3 -m unittest tests/regtest.py --failfast || TEST_EXIT_CODE=$?
+ tar -czf test_wallets.tar.gz /tmp/alice /tmp/bob /tmp/carol || true
+ exit ${TEST_EXIT_CODE:-0}
+ # if any test fails, the test will get aborted (--failfast) and the wallet directories will be
+ # available for download in the GitHub Actions UI as test artifact
+ - name: Upload test wallets (on failure)
+ if: failure()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: test-wallets
+ path: test_wallets.tar.gz
+ if-no-files-found: ignore
+ retention-days: 7
+ - name: Dump service logs (on failure)
+ if: failure()
+ run: |
+ echo "--- bitcoind log ---"
+ tail -200 /tmp/bitcoind.log || true
+ echo "--- electrumx log ---"
+ tail -200 /tmp/electrumx.log || true
diff --git a/.github/workflows/security-review.yml b/.github/workflows/security-review.yml
new file mode 100644
index 000000000000..021ae10df4cb
--- /dev/null
+++ b/.github/workflows/security-review.yml
@@ -0,0 +1,93 @@
+name: security-review
+
+on:
+ pull_request_target:
+ workflow_dispatch:
+ inputs:
+ pr_number:
+ description: 'PR number to review'
+ required: true
+ type: string
+
+permissions:
+ contents: read
+# pull-requests: write # required for the script to post review comments via GITHUB_TOKEN
+
+concurrency:
+ group: security-review-pr-${{ github.event.pull_request.number || inputs.pr_number }}
+ cancel-in-progress: true
+
+jobs:
+ security-review:
+ name: "security review: Claude Code"
+ runs-on: ubuntu-24.04
+
+ # Auto-run only for maintainers (and their forks).
+ # External contributors trigger manually so we can first review if their PR modifies
+ # the Python script this task calls (they could attempt to exfiltrate the api key through the script).
+ if: |
+ github.event_name == 'workflow_dispatch' ||
+ contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association)
+
+ env:
+ LD_LIBRARY_PATH: contrib/_saved_secp256k1_build/
+ ELECTRUM_ECC_DONT_COMPILE: "1"
+ steps:
+ - name: Checkout PR head
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ ref: refs/pull/${{ github.event.pull_request.number || inputs.pr_number }}/head
+ fetch-depth: 0
+
+ - name: Setup Node
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version: '20'
+
+ - name: Install Claude Code CLI
+ run: sudo npm install -g @anthropic-ai/claude-code
+
+ # install Python and dependencies so the llm can quickly access the dependencies source and execute code/tests
+ - name: Setup Python
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ with:
+ python-version: '3.14'
+ cache: 'pip'
+ cache-dependency-path: |
+ contrib/requirements/requirements-ci.txt
+ contrib/requirements/requirements.txt
+
+ - name: Cache libsecp256k1
+ id: cache-libsecp
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: contrib/_saved_secp256k1_build
+ key: libsecp-${{ runner.os }}-${{ hashFiles('contrib/make_libsecp256k1.sh') }}
+
+ - name: Build libsecp256k1
+ if: steps.cache-libsecp.outputs.cache-hit != 'true'
+ run: |
+ sudo apt-get update
+ sudo apt-get -y install automake libtool
+ ./contrib/make_libsecp256k1.sh
+ mkdir -p contrib/_saved_secp256k1_build
+ cp electrum/libsecp256k1.so.* contrib/_saved_secp256k1_build/
+
+ - name: Install Qt/QML runtime deps
+ run: |
+ sudo apt-get update
+ sudo apt-get -y install libgl1 libegl1 libxkbcommon0 libdbus-1-3
+
+ - name: Install Python dependencies
+ run: |
+ pip install -r contrib/requirements/requirements-ci.txt
+ pip install ".[tests,qml_gui]"
+
+ - name: Run Claude Code security review
+ env:
+ PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
+ BASE_BRANCH: ${{ github.event.pull_request.base.ref || 'master' }}
+ # needs to be set in the GitHub repository settings
+ CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+ # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # can be enabled to make claude comment on PRs
+ run: python3 contrib/ci/claude_security_review.py
diff --git a/.github/workflows/submodules.yml b/.github/workflows/submodules.yml
new file mode 100644
index 000000000000..9ae2996b8c6f
--- /dev/null
+++ b/.github/workflows/submodules.yml
@@ -0,0 +1,22 @@
+name: check-submodules
+
+on:
+ push:
+ tags: ['*']
+
+permissions:
+ contents: read
+
+jobs:
+ check-submodules:
+ runs-on: ubuntu-24.04
+ steps:
+ - name: Checkout (with submodules and tags)
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 0
+ submodules: true
+ - name: Fetch all tags
+ run: git fetch --all --tags
+ - name: Run check_submodules.sh
+ run: ./contrib/deterministic-build/check_submodules.sh
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 000000000000..df96be4a5de4
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,185 @@
+name: tests
+
+on:
+ push:
+ branches: [master]
+ tags: ['*']
+ pull_request:
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
+
+permissions:
+ contents: read
+
+jobs:
+ flake8-mandatory:
+ name: "linter: Flake8 Mandatory"
+ runs-on: ubuntu-24.04
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - name: Set up Python
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ with:
+ python-version: "3.10"
+ cache: 'pip'
+ - name: Install flake8
+ run: pip install "flake8==7.3.0" "flake8-bugbear==25.10.21"
+ - name: Run flake8
+ # list of error codes:
+ # - https://flake8.pycqa.org/en/latest/user/error-codes.html
+ # - https://pycodestyle.pycqa.org/en/latest/intro.html#error-codes
+ # - https://github.com/PyCQA/flake8-bugbear/tree/8c0e7eb04217494d48d0ab093bf5b31db0921989#list-of-warnings
+ run: |
+ flake8 . --count \
+ --select="E9,E101,E129,E273,E274,E703,E71,E722,F5,F6,F7,F8,W191,W29,B,B909" \
+ --ignore="B007,B009,B010,B036,B042,F541,F841" \
+ --show-source --statistics \
+ --exclude "*_pb2.py,electrum/_vendor/"
+
+ ban-unicode:
+ name: "linter: ban unicode"
+ runs-on: ubuntu-24.04
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - name: Set up Python
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ with:
+ python-version: "3.10"
+ - name: Run ban_unicode
+ run: ./contrib/ban_unicode.py
+
+ # unittests using the 'latest' runtime python-dependencies
+ unittests:
+ name: "unittests: py${{ matrix.python }}${{ matrix.debug && ', debug-mode' || '' }}"
+ runs-on: ubuntu-24.04
+ needs: [flake8-mandatory]
+ strategy:
+ fail-fast: false
+ matrix:
+ python: ["3.10", "3.11", "3.12", "3.13", "3.14"]
+ debug: [false]
+ include:
+ - python: "3.14"
+ debug: true
+ env:
+ LD_LIBRARY_PATH: contrib/_saved_secp256k1_build/
+ PYTHONASYNCIODEBUG: ${{ matrix.debug && '1' || '' }}
+ PYTHONDEVMODE: ${{ matrix.debug && '1' || '' }}
+ ELECTRUM_ECC_DONT_COMPILE: "1"
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 0 # full clone for coveralls
+ - name: Set up Python
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ with:
+ python-version: ${{ matrix.python }}
+ cache: 'pip'
+ cache-dependency-path: |
+ contrib/requirements/requirements-ci.txt
+ contrib/requirements/requirements.txt
+ - name: Cache libsecp256k1
+ id: cache-libsecp
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: contrib/_saved_secp256k1_build
+ key: libsecp-${{ runner.os }}-${{ hashFiles('contrib/make_libsecp256k1.sh') }}
+ - name: Build libsecp256k1
+ if: steps.cache-libsecp.outputs.cache-hit != 'true'
+ run: |
+ sudo apt-get update
+ sudo apt-get -y install automake libtool
+ ./contrib/make_libsecp256k1.sh
+ mkdir -p contrib/_saved_secp256k1_build
+ cp electrum/libsecp256k1.so.* contrib/_saved_secp256k1_build/
+ - name: Install Qt/QML runtime deps
+ run: |
+ sudo apt-get update
+ sudo apt-get -y install libgl1 libegl1 libxkbcommon0 libdbus-1-3
+ - name: Install Python dependencies
+ run: |
+ pip install -r contrib/requirements/requirements-ci.txt
+ pip install ".[tests,qml_gui]"
+ - name: Log versions
+ run: python3 --version && pip freeze --all
+ - name: Run pytest with coverage
+ run: |
+ coverage run --source=electrum \
+ "--omit=electrum/gui/*,electrum/plugins/*,electrum/scripts/*" \
+ -m pytest tests -v
+ coverage report
+ - name: Upload to Coveralls
+ if: matrix.python == '3.10' && !matrix.debug
+ env:
+ # 'COVERALLS_REPO_TOKEN' needs to be set in the GitHub repository settings
+ # This is a "repo token", NOT a "Personal API Token"!
+ # ref https://coveralls.io/github/spesmilo/electrum/settings
+ COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
+ CI_NAME: github-actions
+ CI_BUILD_NUMBER: ${{ github.run_id }}
+ CI_JOB_ID: ${{ github.job }}-${{ github.run_attempt }}
+ CI_BUILD_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ CI_BRANCH: ${{ github.ref_name }}
+ CI_PULL_REQUEST: ${{ github.event.pull_request.number }}
+ # the repo token will be empty when pull requests from forks get opened
+ # so we won't upload on every pull request, but it will run again
+ # with the token once the PR gets merged.
+ run: if [ -n "$COVERALLS_REPO_TOKEN" ]; then coveralls; else echo "missing COVERALLS_REPO_TOKEN"; fi
+
+ # unittests using the ~same frozen dependencies that are used in the released binaries
+ # note: not using pinned pyqt here, due to "qml_gui" extra
+ unittests-frozen:
+ name: "unittests: py3.10, frozen-deps"
+ runs-on: ubuntu-24.04
+ needs: [flake8-mandatory]
+ env:
+ LD_LIBRARY_PATH: contrib/_saved_secp256k1_build/
+ ELECTRUM_ECC_DONT_COMPILE: "1"
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - name: Set up Python
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ with:
+ python-version: "3.10"
+ cache: 'pip'
+ cache-dependency-path: |
+ contrib/requirements/requirements-ci.txt
+ contrib/requirements/requirements.txt
+ contrib/deterministic-build/requirements.txt
+ contrib/deterministic-build/requirements-binaries.txt
+ contrib/deterministic-build/requirements-build-base.txt
+ - name: Cache libsecp256k1
+ id: cache-libsecp
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: contrib/_saved_secp256k1_build
+ key: libsecp-${{ runner.os }}-${{ hashFiles('contrib/make_libsecp256k1.sh') }}
+ - name: Build libsecp256k1
+ if: steps.cache-libsecp.outputs.cache-hit != 'true'
+ run: |
+ sudo apt-get update
+ sudo apt-get -y install automake libtool
+ ./contrib/make_libsecp256k1.sh
+ mkdir -p contrib/_saved_secp256k1_build
+ cp electrum/libsecp256k1.so.* contrib/_saved_secp256k1_build/
+ - name: Install Qt/QML runtime deps
+ run: |
+ sudo apt-get update
+ sudo apt-get -y install libgl1 libegl1 libxkbcommon0 libdbus-1-3
+ - name: Install Python dependencies (frozen)
+ run: |
+ pip install -r contrib/deterministic-build/requirements-build-base.txt
+ pip install -r contrib/requirements/requirements-ci.txt
+ pip install -r contrib/deterministic-build/requirements.txt -r contrib/deterministic-build/requirements-binaries.txt
+ pip install ".[tests,qml_gui]"
+ - name: Log versions
+ run: python3 --version && pip freeze --all
+ - name: Run pytest
+ run: pytest tests -v
diff --git a/.gitignore b/.gitignore
index dae35e75ff2c..3ef1b05a9909 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,22 +1,53 @@
+.git/
####-*.patch
-*.pyc
+**/*.pyc
*.swp
build/
dist/
*.egg/
-/electrum.py
-contrib/pyinstaller/
Electrum.egg-info/
-gui/qt/icons_rc.py
-locale/
.devlocaltmp/
*_trial_temp
packages
env/
-.tox/
-.buildozer/
+/.venv*/
+.buildozer
+.buildozer_*/
bin/
+.idea
+.mypy_cache
+.vscode
+electrum_data
+.DS_Store
+contrib/trigger_website
+contrib/trigger_binaries
-# tox files
+# tests/tox
+.tox/
.cache/
.coverage
+.pytest_cache
+
+# build workspaces
+contrib/build-wine/tmp/
+contrib/build-wine/build/
+contrib/build-wine/.cache/
+contrib/build-wine/dist/
+contrib/build-wine/signed/
+contrib/build-linux/appimage/build/
+contrib/build-linux/appimage/.cache/
+contrib/osx/.cache/
+contrib/osx/build-venv/
+contrib/android/android_debug.keystore
+contrib/android/.cache/
+contrib/secp256k1/
+contrib/zbar/
+contrib/libusb/
+contrib/.venv_make_packages/
+
+# shared objects
+electrum/*.so
+electrum/*.so.*
+electrum/*.dll
+electrum/*.dylib
+contrib/osx/*.dylib
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 000000000000..12fb13473929
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,7 @@
+[submodule "electrum/locale"]
+ path = electrum/locale
+ url = https://github.com/spesmilo/electrum-locale
+ ignore = dirty
+[submodule "electrum/plugins/keepkey/keepkeylib"]
+ path = electrum/plugins/keepkey/keepkeylib
+ url = https://github.com/spesmilo/electrum-keepkeylib.git
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 1a86f4f48dcf..000000000000
--- a/.travis.yml
+++ /dev/null
@@ -1,14 +0,0 @@
-sudo: false
-language: python
-python:
- - "3.5"
- - "3.6"
-install:
- - pip install tox
- - pip install tox-travis
- - pip install python-coveralls
-script:
- - tox
-after_success:
- - if [ "$TRAVIS_BRANCH" = "master" ]; then pip install pycurl requests && contrib/make_locale; fi
- - coveralls
diff --git a/AUTHORS b/AUTHORS
index 9cff06784e0c..5cecf9e75ad2 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -3,10 +3,11 @@ Animazing / Tachikoma - Styled the new GUI. Mac version.
Azelphur - GUI stuff.
Coblee - Alternate coin support and py2app support.
Deafboy - Ubuntu packages.
+Soren Stoutner - Debian packages and some Qt GUI layout.
EagleTM - Bugfixes.
ErebusBat - Mac distribution.
Genjix - Porting pro-mode functionality to lite-gui and worked on server
Slush - Work on the server. Designed the original Stratum spec.
Julian Toash (Tuxavant) - Various fixes to the client.
rdymac - Website and translations.
-kyuupichan - Miscellaneous.
\ No newline at end of file
+kyuupichan - Miscellaneous.
diff --git a/Info.plist b/Info.plist
deleted file mode 100644
index a8f58f733958..000000000000
--- a/Info.plist
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
- CFBundleURLTypes
-
-
- CFBundleURLName
- bitcoin
- CFBundleURLSchemes
-
- bitcoin
-
-
-
- LSArchitecturePriority
-
- x86_64
- i386
-
-
-
diff --git a/LICENCE b/LICENCE
index b8bb97185926..85fda4eb3382 100644
--- a/LICENCE
+++ b/LICENCE
@@ -1,5 +1,8 @@
The MIT License (MIT)
+Copyright (c) 2011-2024 The Electrum developers
+Copyright (c) 2011-2024 Thomas Voegtlin
+
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
diff --git a/MANIFEST.in b/MANIFEST.in
index 4fa5491a6d48..2288c739e2c6 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,16 +1,31 @@
include LICENCE RELEASE-NOTES AUTHORS
-include README.rst
-include electrum.conf.sample
+include README.md
include electrum.desktop
include *.py
-include electrum
-recursive-include lib *.py
-recursive-include gui *.py
-recursive-include plugins *.py
+include run_electrum
+include org.electrum.electrum.metainfo.xml
recursive-include packages *.py
recursive-include packages cacert.pem
-include app.fil
-include icons.qrc
-recursive-include icons *
-recursive-include scripts *
+include contrib/requirements/requirements*.txt
+include contrib/deterministic-build/requirements*.txt
+include contrib/*.sh
+
+graft electrum
+graft tests
+graft contrib/udev
+
+exclude electrum/*.so
+exclude electrum/*.so.0
+exclude electrum/*.dll
+exclude electrum/*.dylib
+
+global-exclude __pycache__
+global-exclude *.py[co~]
+global-exclude *.py.orig
+global-exclude *.py.rej
+global-exclude .git
+
+# We include both source (.po) and compiled (.mo) locale files (if present).
+# When building the "sourceonly" tar.gz, the build script explicitly deletes the compiled files.
+# exclude electrum/locale/locale/*/LC_MESSAGES/electrum.mo
diff --git a/README.md b/README.md
new file mode 100644
index 000000000000..6fa0caccd1c9
--- /dev/null
+++ b/README.md
@@ -0,0 +1,156 @@
+# Electrum - Lightweight Bitcoin client
+
+```
+Licence: MIT Licence
+Author: Thomas Voegtlin
+Language: Python (>= 3.10)
+Homepage: https://electrum.org/
+```
+
+[](https://github.com/spesmilo/electrum/actions/workflows/builds.yml)
+[](https://coveralls.io/github/spesmilo/electrum?branch=master)
+[](https://crowdin.com/project/electrum)
+
+
+## Getting started
+
+_(If you've come here looking to simply run Electrum,
+[you may download it here](https://electrum.org/#download).)_
+
+Electrum 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:
+
+```
+$ sudo apt-get install libsecp256k1-dev
+$ ELECTRUM_ECC_DONT_COMPILE=1 python3 -m pip install --user ".[gui,crypto]"
+```
+
+### Not pure-python dependencies
+
+#### Qt GUI
+
+If you want to use the Qt interface, install the Qt dependencies:
+```
+$ sudo apt-get install python3-pyqt6
+```
+
+#### libsecp256k1
+
+For elliptic curve operations,
+[libsecp256k1](https://github.com/bitcoin-core/secp256k1)
+is a required dependency.
+
+If you "pip install" Electrum, by default libsecp will get compiled locally,
+as part of the `electrum-ecc` dependency. This can be opted-out of,
+by setting the `ELECTRUM_ECC_DONT_COMPILE=1` environment variable.
+For the compilation to work, besides a C compiler, you need at least:
+```
+$ sudo apt-get install automake libtool
+```
+If you opt out of the compilation, you need to provide libsecp in another way, e.g.:
+```
+$ sudo apt-get install libsecp256k1-dev
+```
+
+#### cryptography
+
+Due to the need for fast symmetric ciphers,
+[cryptography](https://github.com/pyca/cryptography) is required.
+Install from your package manager (or from pip):
+```
+$ sudo apt-get install python3-cryptography
+```
+
+#### hardware-wallet support
+
+If you would like hardware wallet support,
+[see this](https://github.com/spesmilo/electrum-docs/blob/master/hardware-linux.rst).
+
+
+### Running from tar.gz
+
+If you downloaded the official package (tar.gz), you can run
+Electrum from its root directory without installing it on your
+system; all the pure python dependencies are included in the 'packages'
+directory. To run Electrum from its root directory, just do:
+```
+$ ./run_electrum
+```
+
+You can also install Electrum on your system, by running this command:
+```
+$ sudo apt-get install python3-setuptools python3-pip
+$ python3 -m pip install --user .
+```
+
+This will download and install the Python dependencies used by
+Electrum instead of using the 'packages' directory.
+It will also place an executable named `electrum` in `~/.local/bin`,
+so make sure that is on your `PATH` variable.
+
+
+### Development version (git clone)
+
+_(For OS-specific instructions, see [here for Windows](contrib/build-wine/README_windows.md),
+and [for macOS](contrib/osx/README_macos.md))_
+
+Check out the code from GitHub:
+```
+$ git clone https://github.com/spesmilo/electrum.git
+$ cd electrum
+$ git submodule update --init
+```
+
+Run install (this should install dependencies):
+```
+$ python3 -m pip install --user -e .
+```
+
+Create translations (optional):
+```
+$ sudo apt-get install gettext
+$ ./contrib/locale/build_locale.sh electrum/locale/locale electrum/locale/locale
+```
+
+Finally, to start Electrum:
+```
+$ ./run_electrum
+```
+
+### Run tests
+
+Run unit tests with `pytest`:
+```
+$ pytest tests -v
+```
+(can be parallelized with `-n auto` option, using [`pytest-xdist`](https://github.com/pytest-dev/pytest-xdist) plugin)
+
+To run a single file, specify it directly like this:
+```
+$ pytest tests/test_bitcoin.py -v
+```
+
+## Creating Binaries
+
+- [Linux (tarball)](contrib/build-linux/sdist/README.md)
+- [Linux (AppImage)](contrib/build-linux/appimage/README.md)
+- [macOS](contrib/osx/README.md)
+- [Windows](contrib/build-wine/README.md)
+- [Android](contrib/android/Readme.md)
+
+
+## Contributing
+
+Any help testing the software, reporting or fixing bugs, reviewing pull requests
+and recent changes, writing tests, or helping with outstanding issues is very welcome.
+Implementing new features, or improving/refactoring the codebase, is of course
+also welcome, but to avoid wasted effort, especially for larger changes,
+we encourage discussing these on the issue tracker or IRC first.
+
+Besides [GitHub](https://github.com/spesmilo/electrum),
+most communication about Electrum development happens on IRC, in the
+`#electrum` channel on Libera Chat. The easiest way to participate on IRC is
+with the web client, [web.libera.chat](https://web.libera.chat/#electrum).
+
+Please improve translations on [Crowdin](https://crowdin.com/project/electrum).
diff --git a/README.rst b/README.rst
deleted file mode 100644
index 7d176f23b6b5..000000000000
--- a/README.rst
+++ /dev/null
@@ -1,115 +0,0 @@
-Electrum - Lightweight Bitcoin client
-=====================================
-
-::
-
- Licence: MIT Licence
- Author: Thomas Voegtlin
- Language: Python
- Homepage: https://electrum.org/
-
-
-.. image:: https://travis-ci.org/spesmilo/electrum.svg?branch=master
- :target: https://travis-ci.org/spesmilo/electrum
- :alt: Build Status
-.. image:: https://coveralls.io/repos/github/spesmilo/electrum/badge.svg?branch=master
- :target: https://coveralls.io/github/spesmilo/electrum?branch=master
- :alt: Test coverage statistics
-
-
-
-
-
-
-Getting started
-===============
-
-Electrum is a pure python application. If you want to use the
-Qt interface, install the Qt dependencies::
-
- sudo apt-get install python3-pyqt5
-
-If you downloaded the official package (tar.gz), you can run
-Electrum from its root directory, without installing it on your
-system; all the python dependencies are included in the 'packages'
-directory. To run Electrum from its root directory, just do::
-
- ./electrum
-
-You can also install Electrum on your system, by running this command::
-
- sudo apt-get install python3-setuptools
- python3 setup.py install
-
-This will download and install the Python dependencies used by
-Electrum, instead of using the 'packages' directory.
-
-If you cloned the git repository, you need to compile extra files
-before you can run Electrum. Read the next section, "Development
-Version".
-
-
-
-Development version
-===================
-
-Check out the code from Github::
-
- git clone git://github.com/spesmilo/electrum.git
- cd electrum
-
-Run install (this should install dependencies)::
-
- python3 setup.py install
-
-Compile the icons file for Qt::
-
- sudo apt-get install pyqt5-dev-tools
- pyrcc5 icons.qrc -o gui/qt/icons_rc.py
-
-Compile the protobuf description file::
-
- sudo apt-get install protobuf-compiler
- protoc --proto_path=lib/ --python_out=lib/ lib/paymentrequest.proto
-
-Create translations (optional)::
-
- sudo apt-get install python-pycurl gettext
- ./contrib/make_locale
-
-
-
-
-Creating Binaries
-=================
-
-
-To create binaries, create the 'packages' directory::
-
- ./contrib/make_packages
-
-This directory contains the python dependencies used by Electrum.
-
-Mac OS X / macOS
---------
-
-::
-
- # On MacPorts installs:
- sudo python3 setup-release.py py2app
-
- # On Homebrew installs:
- ARCHFLAGS="-arch i386 -arch x86_64" sudo python3 setup-release.py py2app --includes sip
-
- sudo hdiutil create -fs HFS+ -volname "Electrum" -srcfolder dist/Electrum.app dist/electrum-VERSION-macosx.dmg
-
-Windows
--------
-
-See `contrib/build-wine/README` file.
-
-
-Android
--------
-
-See `gui/kivy/Readme.txt` file.
diff --git a/RELEASE-NOTES b/RELEASE-NOTES
index a0894e0a4aed..710ac1aaeb35 100644
--- a/RELEASE-NOTES
+++ b/RELEASE-NOTES
@@ -1,8 +1,1720 @@
+# Release 4.7.2 (April 1, 2026)
+ * security fixes and disclosures:
+ - (sev-medium) External Plugin authorization bypass: local code execution
+ - see https://github.com/spesmilo/electrum/security/advisories/GHSA-vw94-r84p-66qf
+ - (sev-low) Nostr Wallet Connect plugin: daily spending limit bypass
+ - see https://github.com/spesmilo/electrum/security/advisories/GHSA-q7m2-785w-r585
+ * General:
+ - changed: set restrictive unix umask (0077) application-wide by default (#10547)
+ - fix: failing assert for wallets with old (2023) still unpaid LN payment requests (#10502)
+ * Qt GUI (desktop):
+ - changed: move LN fee slider to payment dialog (#10516)
+ - fix: auto-close context menu in Addresses and Coins tab when underlying state changes (#10467)
+ - fix: Coins tab: only enable 'fully spend' menu if there are unfrozen coins in selection (#10503)
+ * QML GUI & Android:
+ - changed: wizard: make trustedcoin 2fa secret copyable, open 2fa app for user (#10543)
+ - fix: bump_fee not handling NetworkException properly (#10514)
+ * GUIs:
+ - changed: for new txn OS notifications, don't sum signed balance deltas as they can cancel out,
+ instead explicitly say sent/received (#10507)
+ - fix: wizard: better handle NotLegacySinglesigScriptType exc during private key import (#10538)
+ * Submarine swaps:
+ - changed: cli: skip pending swaps in swapserver history commands (#10528)
+ - fix: Swap Providers window loses track of swap providers if opened for too long (#10528)
+ - fix: use Decimal for SwapManager.percentage: rounding errors would sometimes cause an exception
+ in the fee calculation inverse sanity check, preventing making a swap (#10528)
+ * Lightning:
+ - changed: lnwatcher now subscribes to fewer non-channel-related addresses (#10533)
+ * CLI/RPC:
+ - changed: daemon: forbid "setconfig" command to change rpcserver settings in-flight (#10534)
+ - changed: in GUI mode, only start a limited minimal RPC server (#10548)
+ * Plugins:
+ - nwc (Nostr Wallet Connect):
+ - sync with spec, fix some bugs (#10505, #10511, #10555)
+ - timelock_recovery:
+ - fix: checksum calculation to follow corresponding BIP-0128 (#10524)
+
+
+# Release 4.7.1 (Feb 26, 2026)
+ * Qt GUI (desktop):
+ - new: changelog website accessible from "Help" toolbar menu (#10433)
+ - new: show translation completion percentage in language names (#10479)
+ - new: allow changing font size in console (#10494)
+ - changed: validate Electrum server address input with UI feedback (#10441)
+ - changed: stop showing anchor icon for lightning channels with anchor outputs (3979d70)
+ - fix: broken addresses tab for imported watch-only wallets (#10436)
+ * QML GUI & Android:
+ - new: show translation completion percentage in language names (#10479)
+ - changed: validate Electrum server address input with UI feedback (#10441)
+ - fix: handle Java import error causing startup crash on Android 7 and 8 devices (#10484)
+ * Onchain / Wallet:
+ - fix: improved fee estimation for replacement transactions (#10453)
+ * Database:
+ - fix: handle upgrade failure for users with pending Lightning HTLCs (#10489)
+ * Lightning:
+ - changed: send channel_update alongside node_announcement gossip messages (#10475)
+ - fix: improved safety when revealing preimage on-chain (#10442)
+ - fix: don't attempt to fetch gossip from Tor peers without a proxy enabled (#10448)
+ - fix: handle peer sending back our own channel_update (#10493)
+ * Dependencies:
+ - changed: bump electrum-ecc (and libsecp256k1) from 0.7.0 to 0.7.1 (#10495)
+ * Builds/binaries:
+ - Android:
+ - changed: bump docker base image to Debian 13 (#10452)
+ * Contrib:
+ - changed: translation: stop sorting source strings (6c1e085)
+ - changed: freeze_packages.py: use stdlib "venv" instead of 3rd party virtualenv (4f7b6e8)
+ - fix: add_cosigner.py: compatibility with Python 3.13 (b495ee7)
+ * Plugins:
+ - new: hook 'qt_utxo_menu' for Qt GUI UTXO list (cfe2a57)
+ * Hardware wallets:
+ - new: support Ledger Nano Gen 5 (#10457)
+ * Translations: Call for Proofreaders and Translators.
+ - Localisation of the UI has always been a community effort.
+ Recently we found several examples of vandalism and malicious behaviour
+ among the translated strings, including multiple bitcoin addresses
+ injected into UI strings. One user sent funds to one such address
+ and hence lost money. (see spesmilo/electrum-locale#46)
+ - We added some automated safeguards to try and prevent this in the future,
+ including basic regexes and an LLM proofreader. We also made the ongoing
+ git diffs for updating the frozen translations much smaller to make it
+ realistic to ~review. (see spesmilo/electrum-locale#47, #49, #51)
+ - However, the best solution would be per-language human review.
+ If we had 1-3 proofreaders per language on Crowdin, we could restrict
+ the set of translated strings that gets included in the binaries to the
+ "proofreader-approved" strings. We ask interested people to start contributing
+ and apply to be proofreaders. To get proofreader permissions, send us an email
+ or come to irc, with your crowdin username and some proof of work (such as
+ activity on crowdin in our or another project, contributions to open-source,
+ having an established identity on github/bitcointalk/stackoverflow/..., etc
+ -- just prove being a human and being well-intentioned).
+ (see https://github.com/spesmilo/electrum-locale/issues/47#issuecomment-3914866337)
+
+# Release 4.7.0 (Jan 22, 2026)
+ * Qt GUI (desktop):
+ - new: "Submarine Payments": support reverse swaps to external address (#10303)
+ Allows doing onchain payments from the wallet's lightning balance.
+ - changed: flag console usage in crash reports (#10219)
+ - changed: add "Tools" text to the tools button for increased visibility (#10277)
+ - changed: improved UI feedback for send change to lightning function (#10247)
+ - fix: improve Network Tab behavior when switching connection mode (#10280)
+ - fix: re-add fiat values to csv/json history export (#10209)
+ - fix: not proposing tx batching in some cases (#10204)
+ * QML GUI & Android:
+ - new: allow manual editing of fee/feerate (#10371)
+ This also allows sending sub-1 sat/b transactions on Android.
+ - new: support biometric authentication (#10340)
+ Allows using the Android system lockscreen (e.g. fingerprints)
+ to unlock the wallet and authorize payments.
+ The previous optional built-in PIN code authentication is removed.
+ - changed: make UI compatible with edge-to-edge layout (#10178)
+ - changed: fee histogram colors: extend color palette to cover sub-1 s/b (#10307)
+ - changed: enforce the usage of a single password for all wallet files (#10345)
+ - changed: allow tap-to-focus in the qr code scanner (#10385)
+ - fix: allow opening passwordless wallets (#10423)
+ - fix: also protect address private keys from screenshots (#10426)
+ * Lightning:
+ - new: support LNURL-withdraw/LUD-3 (#9993)
+ Allows scanning QR codes to receive funds on lightning (e.g. ATMs, vouchers).
+ - changed: refactor handling of incoming htlcs (#10230)
+ - changed: collect htlcs failed back to us before re-splitting (#10274)
+ - fix: allow spending channel reserve if anchor channels are closed but not redeemed (2d17252)
+ - fix: logic bug in liquidity hint calculation (#10305)
+ - fix: race resulting in "Not enough balance" error when doing concurrent payments (#10325)
+ - fix: self payments (and rebalance function) (#10271)
+ - fix: gossip exchange with Core Lightning nodes (#10347)
+ - fix: only wait for pending htlcs to get removed if peer is connected (1845143)
+ * Electrum protocol:
+ - new: add support for Electrum Protocol version 1.6 (#10295)
+ See https://electrum-protocol.readthedocs.io/en/latest/protocol-changes.html#version-1-6
+ Min required version is still 1.4.
+ - changed: prevent connecting to server with different genesis hash (#10281)
+ - changed: add warmup budget before batching server rpc calls for faster startup (#10281)
+ - changed: optimistically guess scripthash status on new blocks to reduce network traffic
+ and improve privacy (#10290)
+ - fix: flush network buffer before disconnecting from server (6423323)
+ * Onchain / Wallet:
+ - changed: non-SPV verified transactions now considered unconfirmed (#10216)
+ - changed: always enforce dnssec validation for Openalias (#10349)
+ * Submarine swaps:
+ - new: cli commands to get swap statistics for swapserver operators (#10198):
+ 'swapserver_get_history' and 'swapserver_get_summary'
+ * CLI/RPC:
+ - new: add 'export_lightning_preimage' command (#10242)
+ - changed: return lightning preimage from 'check_hold_invoice' command (#10242)
+ - changed: 'add_peer' now blocks until the connection is established (#10283)
+ - changed: 'version_info' now shows OpenSSL version (828fc56)
+ - fix: print warnings to stderr so output is still valid json (7bfe2dd)
+ - fix: imply enabled proxy when starting with proxy cli option (#10326)
+ * Plugins:
+ - changed: plugins can now use existing cli command names without colliding with builtin commands (9c4c7f0)
+ - changed: Timelock Recovery: check if locking address is ours or script (#10272)
+ - removed: payserver plugin, now an external plugin, moved to spesmilo/electrum-payserver (d36b753)
+ * Hardware wallets:
+ - Coldcard: fix: compatibility with ckcc-protocol v1.5.0 (2172dad)
+ * Contrib:
+ - new: add README to scripts/ directory (5a14a58)
+ * Dependencies:
+ - changed: bump min required electrum-aionostr to 0.1.0 (e188102)
+ * Builds/binaries:
+ - Android:
+ - new: support 16kb page size (#10148)
+ - changed: bump Android target SDK version to 35 (#10178)
+ - changed: bump OpenSSL from 1.1.1w to 3.0.18 (#10332)
+ - changed: switch from cryptography to pycryptodomex (#10332)
+ - changed: bump python version from 3.10.18 to 3.11.14 (#10388)
+ - AppImage:
+ - changed: migrate AppImage build to use modern/maintained appimagetool (#10019)
+
+
+# Release 4.6.2 (Aug 25, 2025)
+ * General:
+ - changed: minrelayfee clamps from [1, 50] to [0.1, 50] sat/vbyte (#10096)
+ - new: add support for "mutinynet" signet test network (#10134)
+ - new: network: don't request same tx from server that we just broadcast to it (#10111)
+ - new: logging: add config.LOGS_MAX_TOTAL_SIZE_BYTES: to limit size on disk (#10159)
+ * QML GUI (Android):
+ - fix: cannot open keystore-encryption-only wallets (#10171)
+ - fix: wizard: restoring from seed broken if already opened a wallet (#10117)
+ - fix: handle invoice validation errors on save (#10122)
+ - fix: sweep: handle network errors gracefully (#10108)
+ - fix: sweep: handle unexpected script_types (#10145)
+ * Qt GUI (desktop):
+ - fix: wizard: hardware device: handle missing xpub (#10109)
+ - fix: wizard: enable-keystore for bip39 seeds and hw devices (#10123)
+ * Lightning:
+ - fix: slow down peers sending too much gossip, and other rate-limits (#10153)
+ * Submarine swaps: several bug fixes and improved reliability.
+ * CLI/RPC:
+ - changed: onchain_history: add back from_height/to_height params (#10119)
+ - changed: reverse_swap: new mandatory parameter 'prepayment' (#10165)
+ - new: get_submarine_swap_providers: added command to fetch swap providers (#10158)
+ * Plugins:
+ - Nostr Cosigner: fix: don't allow saving tx without txid (#10128)
+
+
+# Release 4.6.1 (Aug 5, 2025)
+ * QML GUI (Android):
+ - fix: QR scanner crashes due to null/orphaned View in hierarchy (#10071)
+ - fix: creating a tx with a pre-segwit watchonly wallet (#10042)
+ * CLI/RPC:
+ - fix several bugs related to new hold_invoice APIs. This required
+ minor breaking changes in the new APIs. (#10059, #10082)
+ - add max_cltv, max_fee_msat parameters to lnpay command (#10067)
+ * Hardware wallets:
+ - bitbox02: bump required and bundled library to 7.0.0 (#10040)
+ This should add support for the new BitBox02 "Nova" devices.
+ * General:
+ - rework crash reporter (#10052)
+ - show additional confirmation popup on clicking "Send"
+ - remove the "Never" button and the corresponding config option.
+ The crash reporter is now always shown on uncaught exceptions.
+ This unifies some code paths: the crash-reporter-disabled case
+ was untested and buggy.
+ - don't show reporter multiple times for the "same" exception
+ - new: network: parallelize block-header-chunks downloads (#10033)
+ * Lightning:
+ - wallet: don't spend reserve utxo to create new reserve utxo (#10091)
+ * various UI fixes (#10060, #10062, #10081, ...)
+
+
+# Release 4.6.0 (July 16, 2025)
+ * A 'Terms of Use' screen was added to the install wizard. While the
+ licence remains unchanged, we ask users to agree with the fact that
+ we are not a custodial service or a money transmitter. The Terms of
+ Use screen also makes it clear that all issues are to be resolved
+ in public, and that there is no user support via private channels.
+ * Nostr support: (using new dependency: electrum-aionostr)
+ Electrum now uses Nostr in the context of submarine swaps,
+ and in several plugins. Electrum will not connect to Nostr
+ by default, only if required.
+ * Submarine swaps over Nostr: The Electrum client will connect to
+ Nostr in order to discover submarine swap providers, and to perform
+ related RPCs. This means that:
+ - Anyone can become a swap provider (you need to run an Electrum
+ daemon with the 'swapserver' plugin). Submarine swap providers
+ advertise their fees and their liquidity on Nostr.
+ See https://electrum.readthedocs.io/en/latest/swapserver.html
+ for set-up documentation.
+ - Submarine swap providers do not need to provide an HTTP
+ endpoint, since RPCs are performed via Nostr. They also do not
+ need to have public lightning channels.
+ - Because a decentralized service needs to be trustless, the
+ option to perform zero-confirmation swaps has been removed from
+ Electrum.
+ Note that Electrum connections to Nostr relays are only initiated
+ when the user uses the swap service, and the nostr public key used
+ by the client is ephemeral. In contrast, swap providers use a
+ persisted identity.
+ * Third-party plugins:
+ - Electrum supports the installation of plugins distributed by
+ third-parties as ZIP files. While it has long been possible to
+ install third-party plugins when running Electrum from python
+ sources, the same is now possible when using desktop binaries
+ (Windows, MacOS, Linux). Third-party plugins are installed as ZIP
+ files in the user's electrum data directory.
+ - In order to prevent plugin installation by malware, third-party
+ plugins can only be enabled if the user enters a plugin
+ authorization password (distinct from the wallet password).
+ Setting up that plugin authorization password requires
+ administrator permissions on the local machine; a
+ password-derived public key must be written in the system.
+ * Lightning:
+ - Anchor channels (#9264): Newly created channels use
+ anchor commitments by default. Since sweeping outputs from anchor
+ channels may require external UTXOs, lightning can no longer be
+ enabled in wallets that do not have a software keystore (hardware
+ wallets, watching-only wallets). Existing wallets that are in that
+ situation cannot create new channels.
+ - wallets with anchor channels must always have utxos available (#9536)
+ - support added for onion messages (only CLI for now) (#9039)
+ - lots of fixes and improvements (#8857, #8547, #9700, #9083, ...)
+ * Qt Desktop GUI:
+ - migrate from Qt5 to Qt6 (#9189)
+ - new: screenshot "protection" on Windows (#9898). Inspired by Windows
+ Recall, by default screenshots will contain black rectangles in
+ place of the Electrum windows, to try to avoid leaking secret keys.
+ This is opt-out using a config variable.
+ - exposed option to connect to only a single server (--oneserver)
+ - Wallet file encryption:
+ - Non-multisig hardware wallet files can now be encrypted with
+ either using the hardware device or (new) a password. (#5561)
+ - The option to have a password-protected wallet without file
+ encryption has been removed from the Qt GUI. It is still possible
+ to create such a wallet using the command line.
+ - Wallet unlocking:
+ - Wallets can be unlocked in the Qt GUI. When a password-protected
+ wallet is unlocked, its password is kept in memory, and signing
+ transactions will not require to enter the password. The unlocked
+ state is rendered by the 'open lock' icon in the status bar.
+ - If a wallet needs to sweep anchor channel outputs using extra
+ UTXOs, the operations will be performed without requiring the
+ user password if the wallet is unlocked. If the wallet is locked,
+ the status bar will show a 'password required' button.
+ - Transaction batching: When creating a new payment, if the
+ output can be added to an existing mempool transaction, the 'New
+ transaction' window will show a drop-down menu, proposing a list of
+ transactions that can be batched with the current payment. This
+ replaces the previous 'batch' option checkbox, and gives more
+ control to the user.
+ - Keystore enabling/disabling (Qt):
+ - It is now possible to add a seed
+ to an existing watching-only wallet, or to a keystore within a
+ multisig wallet. Similarly, it is possible to pair a watching-only
+ keystore with a hardware device. These operations are performed
+ from the 'Wallet Information' dialog.
+ - Lightning address contacts:
+ - It is now possible to create contacts with (lnurl type) lightning
+ addresses as payment identifier.
+ - show warnings on wallet close if there are sensitive pending operations,
+ e.g. when in the middle of doing a swap (#9715)
+ - some performance improvements for large wallets (#9958, #9967, #9968)
+ - qr-reader: macos: add runtime requesting of camera permission (#9955)
+ * Accounting rules: In order to properly handle on-chain transactions
+ created by lightning channel force closures, we consider that funds
+ successfully redeemed from a script with several possible
+ recipients have never left the final recipient's wallet. This
+ avoids having to write balance changes that are cancelled
+ later. The corresponding addresses are rendered in the GUI as
+ 'accounting addresses' (in orange).
+ * New plugins:
+ - Nostr Wallet Connect: This plugin allows remote control of
+ Electrum lightning wallets via Nostr NIP-47. (#9675)
+ - Nostr Cosigner: This plugin facilitates the exchange of
+ PSBTs between cosigners of a multisig wallet. It replaces the
+ former 'Cosigner pool' plugin. Instead of relying on a central
+ server, it uses Nostr to send/receive PSBTs. (#9261)
+ - Timelock Recovery: A timelock based inheritance scheme.
+ See timelockrecovery.com (#9589)
+ * CLI:
+ - The command line help has been improved; parameters are
+ documented in the same docstring as the command they belong to.
+ - If the --wallet parameter passed to a command is a simple filename,
+ it is now interpreted as relative to the users wallets directory,
+ rather than to the current working directory.
+ - Plugins may add extra commands to the CLI. Plugin commands must
+ be prefixed with the plugin's internal name.
+ - Support for hold invoices.
+ - new commands:
+ - listconfig, helpconfig, unsetconfig
+ - onchain_capital_gains (was previously a field of onchain_history)
+ - {add,settle,cancel,check}_hold_invoice
+ - send_onion_message, get_blinded_path_via
+ - wait_for_sync
+ * General:
+ - Mitigate against dust attacks; Add option to avoid spending from
+ used addresses. (#9636)
+ - Restrict process memory access on Linux. (#9749)
+ - locale: syntax-check i18n translations at runtime. Malformed translation
+ strings are now less likely to cause errors: instead we fallback to the
+ original English string (#10011)
+ - fix: would sometimes hang on startup if system clock jumped backwards (#9802)
+ * QML GUI (Android):
+ - "Sweep key" feature ported to mobile
+ - Estimate amount when Max is checked
+ - exposed option to connect to only a single server (--oneserver)
+ * Android:
+ - replace QR code scanning library to make scanning fun again (#9983)
+ - properly ask for (notification) OS permission access. (#9682)
+ - add option to prevent the app touching the screen brightness (#9321)
+ * Electrum protocol: add padding and some noise to messages (#9875)
+ * Hardware wallets:
+ - Coldcard: add feature to upload multisig wallet configuration to Coldcard via USB.
+ - KeepKey: we now vendor our fork of keepkeylib,
+ instead of using the unmaintained upstream as an external dependency (#9650)
+ - Ledger:
+ - rm support for "HW.1" and "Nano" (non-S) devices (#9652)
+ - rm dependency: btchip-python (#9370)
+ * Builds/binaries:
+ - new minimum OS requirements:
+ - Windows: x86_64, Windows 10 (1809)
+ note: 32-bit Windows is no longer supported.
+ - macOS: 11 "Big Sur"
+ - Linux AppImage: x86_64, glibc 2.31 ("debian 11"-equivalent)
+ * Dependencies:
+ - the minimum required python version was increased: 3.8->3.10 (#9418)
+ - new first-party dep: electrum-aionostr
+ - forked the seemingly unmaintained davestgermain/aionostr library
+ - new first-party dep: electrum-ecc
+ - split out our existing libsecp256k1 python bindings into
+ this separate package
+
+
+# Release 4.5.8 (Oct 23, 2024)
+ * Qt Desktop GUI:
+ - fix: regression: bump_fee and dscancel dialogs erroring (#9273)
+
+
+# Release 4.5.7 (Oct 21, 2024)
+ * General:
+ - new: add new historical exchange rate providers: Bitfinex and Bitstamp
+ - fix: wizard regression: 2fa wallet setup erroring (#9253)
+ - fix: python 3.13 compat: could not connect to some self-signed electrum
+ servers with weird TLS certs. As workaround, set pre-3.13 behaviour (#9258)
+ * Lightning:
+ - fix: send update_fee right away after channel_reestablish (3a465593)
+ This fixes a race that can result in a force-closure if we try sending
+ a payment very soon after reestablishing the channel.
+ * Qt Desktop GUI:
+ - fix: show fee warnings also in the transaction dialog (c4fe2796)
+
+
+# Release 4.5.6 (Oct 16, 2024)
+ * General:
+ - new: add support for testnet4 (#9197)
+ - fix: wizard: allow passphrase for some '2fa' seeds (#9088)
+ - fix: trustedcoin wallet wizard continuation if file has keystore-only encryption (#9237)
+ - fix: trustedcoin: sanitize error messages coming from 2fa server
+ - fix: new wizard did not set keystore password if storage was not encrypted (#9147)
+ - changed: set stricter UNIX permissions for log files (fa8595b1)
+ * QML GUI (Android):
+ - new: show seed passphrase in WalletDetails (#9204)
+ - new: set max screen brightness when displaying QR codes (79c08536)
+ - fix: crash due to ConcurrentModificationException (450b9a0)
+ - fix: issue deactivating PIN when no wallet loaded (#8366)
+ - fix: only allow Channel Backup import on Lightning-enabled wallets (8d9bcda)
+ * Qt Desktop GUI:
+ - fix: scanning multi (privkeys, addresses) from QR (4dc64e4)
+ * Hardware wallets:
+ - ColdCard: new: export multisig wallet to coldcard over USB (#7682)
+ - Trezor:
+ - new: add support for new device "Safe 5" (#9171)
+ - update: fix compat with and bump pinned library to 0.13.9 (#9141)
+ - Ledger:
+ - new: add support for new device "Flex" (#9179)
+ - update: bump pinned library to 0.3.0, raise max lib to <0.4 (719292f8)
+ - Jade: update: bump library to 1.0.31 (9a84bb32)
+ * CLI/RPC:
+ - changed: require wallet password for lnpay and similar commands (#9236)
+ (This is in addition to the wallet needing to be loaded,
+ and requiring read access to the config file)
+ * Builds/binaries:
+ - changed: include unit tests in tarballs (#9207)
+ - android:
+ - changed: set target_sdk_version to 34 (2917fde5)
+ - update: bump python version (3.8->3.10) (08127a60)
+ - work towards F-Droid inclusion:
+ - reproducible apks: strip file path prefix from .pyc files (6ebdbf04)
+ - add fastlane metadata for f-droid (#9211)
+ - change versionCode calculation (#9221)
+ - build.gradle: set android.dependenciesInfo.includeInApk=false (af18df10)
+ - contrib/release_www.sh: put android versionCode in "version" file (#9233)
+
+
+# Release 4.5.5 (May 30, 2024)
+ * General:
+ - fix: timeout error shadowed by aiorpcx cancellation bug (#8954)
+ - changed: Fiat exchange rates: do not overwrite the locally saved historical
+ data. Instead, merge old and new data (a2fb70d6). This also ~fixes the
+ CoinGecko historical API by only asking for the last 365 days.
+ - update: support latest revision of SLIP-39 mnemonic spec (to restore) (#9059)
+ * Lightning:
+ - new: unify max fee bounds for payments, make it configurable (#9041)
+ - changed: trampoline fees: instead of hardcoded list, use
+ exponential search, capped by configurable budget (#9033)
+ - fix: opening new channels with peer that has .onion address (#9002)
+ * Dependencies:
+ - remove bitstring (#9020)
+ * QML GUI (Android):
+ - new: add tx options to ConfirmTxDialog, RbfBumpFeeDialog (#8909)
+ - various UI fixes (#9018, 472a65eb)
+ * Qt Desktop GUI:
+ - fix: save notes whenever modified (#8951)
+ - fix: offline 2fa wallet creation failing in some cases (#9037)
+ - various UI fixes (#8962, #8874, #9012, 1047200a, #9058)
+ * Hardware wallets:
+ - Bitbox02: fix: call pairing dialog when necessary (#8971)
+ - Jade: update: bump library to 1.0.29 (#9007)
+ * Binaries:
+ - new: add AppArmor profiles for tarball and AppImage (#9003)
+
+
+# Release 4.5.4 (March 14, 2024)
+ * General:
+ - fix: failing WalletDB upgrade(58) in 4.5.3 (#8913), for wallets with
+ partial txs saved into the history as local txs
+ * Lightning:
+ - changed: use longer final_cltv_delta for client-normal-swap, to
+ give more time for user to come back online while doing the swap (#8940)
+ - changed: create trampoline onions even when directly paying
+ a trampoline forwarder node (777c2ffb)
+ * Hardware wallets:
+ - Trezor:
+ - fix: allow adding SLIP-19 ownership proofs to complete inputs (#8910)
+ * Plugins:
+ - fix: a race in swapserver when handling server-normal-swaps (#8825)
+
+
+# Release 4.5.3 (February 23, 2024)
+ * General:
+ - changed: label tx sizes as "vbytes", and feerates as "sat/vbyte" (#8864)
+ - fix: wizard regression not able to use HWW as cosigner for new wallets (643fbec)
+ - fix: onchain invoice paid detection broken if jsonpatch enabled (#8842)
+ - fix: program not starting because of bad "proxy" config value (#8837)
+ - fix: wizard: don't log sensitive values: replace blacklist with whitelist (638fdf11)
+ * Qt Desktop GUI:
+ - new: basic "add server as bookmark" functionality (#8865)
+ - fix: potential race condition in wizard page construction (c78a90a)
+ - fix: don't use lightning invoice when user specifies MAX amount (#8900)
+ - various UI fixes (#8874, 2882c4b, #8889, 66af6e6)
+ * QML GUI (Android):
+ - fix potential concurrency issue loading wallet (#8355)
+ - fix: wizard: fails to restore from 2fa seed: KeyError: 'x1' (#8861)
+ - various UI fixes (50a53aa, 0a6b2d5, #8782, 6738e1e, c0b8927, 016e500, #8898)
+ * Hardware wallets:
+ - Trezor:
+ - new: support SLIP-19 ownership proofs, for trezor-based Standard_Wallets (#8871)
+ - fix: regression in sign_transaction for trezor one for multisig (#8813)
+ * CLI/RPC:
+ - changed: nicer error messages and error-passing (#8888)
+ * Lightning:
+ - fix: timing issue in lnpeer.reestablish_channel, for replaying unacked updates (79d88dcb)
+
+
+# Release 4.5.2 (January 20, 2024)
+ * Qt Desktop GUI:
+ - fix crash during startup/wizard-open (#8833)
+
+
+# Release 4.5.1 (January 19, 2024)
+ * Lightning:
+ - fix: MPP regression when using gossip that made paying small invoices fail (95c55c542)
+ - fix: better handle dataloss (#8814)
+ - allow manually requesting force-close in WE_ARE_TOXIC state
+ - fix some timing issues
+ * General:
+ - localization: never translate CLI/RPC (0e5a1380)
+ - localization: simplify how default language is chosen (0e5a1380)
+ * QML GUI (Android):
+ - bump min required android version from android 5.0 to 6.0 (#8761)
+ (older versions have not been working in practice since at least 4.4.0)
+ - properly refresh history if addresses are deleted from imported wallets (#8782)
+ - fix crash when LNURLp is scanned/pasted (#8822)
+ - fix crash for new wallets having cosigner using hww #8808)
+ - fix crash in finalizer when txid is undefined (#8807)
+ - various UI fixes (291f0ce, 3d9996a, ec81f00)
+ * Qt Desktop GUI:
+ - also support unfinished wallets when opened through File>Open (#8809)
+ - fix handler for OpenFileEventFilter (6a28ef5)
+
+
+# Release 4.5.0 (January 12, 2024)
+ * General:
+ - remove SSL options from config (012ce1c)
+ - make number of logfiles to keep configurable (5e8b14f)
+ - refactored SimpleConfig and added ConfigVars (#8454)
+ - incremental writes of wallet file (#8493)
+ - add warnings and prompt users when signing txs with non-default sighashes (#8687)
+ - refactored bip21/bolt11/lnurl/etc-handling into PaymentIdentifiers (#8462)
+ - add option to merge duplicate outputs (#8474)
+ - fix: consider bip21 URIs as invalid if they contain unknown req-* param (#8781)
+ * Lightning:
+ - fix BOLT-04 "MUST set `short_channel_id` to the `short_channel_id` used by the incoming onion" (ca93af2)
+ - add support for hold invoices (1acf426)
+ - add support for bundled payments (c4eb7d8)
+ - various MPP improvements (#7987, ..)
+ - support large channels (40f2087)
+ - new flow for normal submarine swaps (fd10ae3)
+ - the client now uses hold invoices, just like the server
+ - the client waits until HTLCs are received before going on-chain
+ - the user may cancel the swaps during that waiting time
+ - don't create invoice with duplicate route hints (a3997f8)
+ - don't set channel OPEN before channel_ready has been both sent and received (#8641)
+ - if trampoline is enabled, do not add non-trampoline nodes to invoices (120faa4)
+ * QML GUI (Android):
+ - port to Qt6 (#8545)
+ - fix regression for lnurl-pay (#8585)
+ - fix invoice amount bounds check (#8582)
+ - fix places where text was rendered off-screen for certain translations (#8611)
+ - fix lnworker undefined when node alias requested (#8635)
+ - fix BIP39 cosigner script type must be same as primary (8cd95f1)
+ - fix: never use current fiat exchange rate for old historical amounts (#8788)
+ - better handle android back-gesture (#8464)
+ - new: show private key in address details (016b5eb)
+ - new: show tx inputs in TxDetails and other dialogs (#8772)
+ - new: label sync plugin toggle (b6863b4)
+ - fix: properly suggest paying BOLT11 invoice onchain if insufficient balance (0a80460)
+ - new: message sign & verify (e5e1e46)
+ - new: allow never expiring payment requests (#8631)
+ - new: add coins/UTXOs to addresses list, add filters (cf91d2e)
+ - new: delete addresses from imported wallet (#8675)
+ - new: add support for lightning address and openalias (03dd38b)
+ - new: add setting to allow screenshots everywhere (0dae1733)
+ - simplify welcome page for first-start network settings (#8737)
+ - various UI fixes (b846eab, #8634, 9ed5f7b, 941f425, b20a4b9, af61b9d, 0fb47c8, 2995bc8, ..)
+ * Qt Desktop GUI:
+ - port wizard to new implementation
+ - fix fiat balance sorting in address list window (#8469, #8478)
+ - remove thousands separator when copying numbers to clipboard (#8479)
+ - new: option to use extra trampoline for legacy payments (b2053c6)
+ - new: send change to lightning option for on-chain payments (649ce97)
+ - new: notes tab for saving text in the (encrypted) wallet file (d691aa07)
+ - simplify welcome page for first-start network settings (#8737)
+ - various UI fixes (#8587, #6526, ..)
+ * Hardware wallets:
+ - Trezor: allow multiple change outputs (#3920)
+ - Trezor: support external pre-signed inputs (#8324)
+ - Bitbox02: update to 6.2.0 (#8459)
+ * Plugins:
+ - new: swapserver plugin (#8489)
+ * Builds/binaries:
+ - update bundled zbar, for security fixes (#8805)
+
+
+# Release 4.4.6 (August 18, 2023) (security update)
+ * Lightning:
+ - security fix: multiple lightning-related security issues have
+ been fixed. See disclosures:
+ - https://github.com/spesmilo/electrum/security/advisories/GHSA-9gpc-prj9-89x7
+ - https://github.com/spesmilo/electrum/security/advisories/GHSA-8r85-vp7r-hjxf
+ - fix: cannot sweep from channel after local-force-close, if using
+ imported channel backup (#8536). Fixing this required adding a
+ new field (local_payment_pubkey) to the channel backup
+ import/export format and bumping its version number
+ (v0->v1). Both v0 and v1 can be imported, and we only export v1
+ backups. When you force close a channel, the GUI will prompt you
+ to save a backup. In that case, you must export the backup using
+ the updated Electrum, and not rely on a backup made with an older
+ release of Electrum. Note that if you request a force close from
+ the remote node or co-op close, you do not need to save a channel
+ backup.
+ - fix: we would sometimes attempt sending MPP even if not supported
+ by the invoice (2cf6173c)
+ * QML GUI:
+ - fix lnurl-pay when config.BTC_AMOUNTS_ADD_THOUSANDS_SEP is True
+ (5b4df759)
+ * Hardware wallets:
+ - Trezor: support longer than 9 character PIN codes (#8526)
+ - Jade: support more custom-built DIY Jade devices (#8546)
+ * Builds/binaries:
+ - include AppStream metainfo.xml in tarballs (#8501)
+ * fix: exceptions in some callbacks got lost and not logged (3e6580b9)
+
+
+# Release 4.4.5 (June 20, 2023)
+ * Hardware wallets:
+ - jade: fix regression in sign_transaction (#8463)
+ * Lightning:
+ - fix "rebalance_channels" function (#8468)
+ * enforce that we run with python asserts enabled,
+ regardless of platform (d1c88108)
+
+
+# Release 4.4.4 (May 31, 2023)
+ * QML GUI:
+ - fix creating multisig wallets involving BIP39 seeds (#8432)
+ - fix "cannot scroll to open a lightning channel" (#8446)
+ - wizard: "confirm seed" screen to normalize whitespaces (#8442)
+ - fix assert on address details screen (#8420)
+ * Qt GUI:
+ - better handle some expected errors in SwapDialog (#8430)
+ * libsecp256k1: bump bundled version to 0.3.2 (10574bb1)
+
+
+# Release 4.4.3 (May 11, 2023)
+ * Intentionally break multisig wallets that have heterogeneous master
+ keys. Versions 4.4.0 to 4.4.2 of Electrum for Android did not check
+ that master keys used the same script type. This may have resulted
+ in the creation of multisig wallets that cannot be spent from
+ with any existing version of Electrum. It is not sure whether any
+ users are affected by this; if there are any, we will publish
+ instructions on how to spend those coins (#8417, #8418).
+ * Qt GUI:
+ - handle expected errors in DSCancelDialog (#8390)
+ - persist addresses tab toolbar "show/hide" state (b40a608b)
+ * QML GUI:
+ - implement bip39 account detection (0e0c7980)
+ - add share toolbutton for outputs in TxDetails (#8410)
+ * Hardware wallets:
+ - Ledger:
+ - fix old bitcoin app support (<2.1): "no sig for ..." (#8365)
+ - bump req ledger-bitcoin (0.2.0+), adapt to API change (30204991)
+ * Lightning:
+ - limit max feature bit we accept to 10_000 (#8403)
+ - do not disconnect on "warning" messages (6fade55d)
+ * fix wallet.get_tx_parents for chain of unconf txs (#8391)
+ * locale: translate more strings when using "default" lang (a0c43573)
+ * wallet: persist frozen state of addresses to disk right away (#8389)
+
+
+# Release 4.4.2 (May 4, 2023)
+ * Qt GUI:
+ - fix undefined var check in swap_dialog (#8341)
+ - really fix "recursion depth exceeded" for utxo privacy analysis (#8315)
+ * QML GUI:
+ - fix signing txs for 2fa wallets (#8368)
+ - fix for wallets with encrypted-keystore but unencrypted-storage (#8374)
+ - properly delete wizard components after use (#8357)
+ - avoid entering loadWallet if daemon is already busy loading (#8355)
+ - no auto capitalization on import and master key text fields (5600375d)
+ - remove Qt virtual keyboard and add Seedkeyboard for seed entry (#8371, #8352)
+ - add runtime toggling of android SECURE_FLAG, to allow screenshots (#8351)
+ - restrict cases where server is shown "lagging" (53d61c01)
+ * fix hardened char "h" vs "'" needed for some hw wallets (#8364, 499f5153)
+ * fix digitalbitbox(1) support (22b8c4e3)
+ * fix wrong type for "history_rates" config option (#8367)
+ * fix issues with wallet.get_tx_parents (a1bfea61, 56fa8325)
+
+
+# Release 4.4.1 (April 27, 2023)
+ * Qt GUI:
+ - fix sweeping (#8340)
+ - fix send tab input_qr_from_camera (#8342)
+ - fix crash reporter showing if send fails on typical errors (#8312)
+ - bumpfee: disallow targeting an abs fee. only allow feerate (#8318)
+ * QML GUI:
+ - fix offline-signing or co-signing pre-segwit txs (#8319)
+ - add option to show onchain address in ReceiveDetailsDialog (#8331)
+ - fix strings unique to QML did not get localized/translated (#8323)
+ - allow paying bip21 uri onchain that has both onchain and bolt11
+ if we cannot pay on LN (#8334, 312e50e9)
+ - virtual keyboard: make buttons somewhat larger (75e65c5c)
+ - fix(?) Android crash with some OS-accessibility settings (#8344)
+ - fix channelopener.connectStr qr scan popping under (#8335)
+ - fix restoring from old mpk (watchonly for "old" seeds) (#8356)
+ * libsecp256k1: add runtime support for 0.3.x, bump bundled to 0.3.1
+ * forbid paying to "http:" lnurls (enforce https or .onion) (1b5c7d46)
+ * fix wallet.bump_fee "decrease payment" erroring on too high target
+ fee rate (#8316)
+ * fix performance regressions in tx logic (ee521545, 910832c1)
+ * fix "recursion depth exceeded" for utxo privacy analysis (#8315)
+
+
+# Release 4.4.0 (April 18, 2023)
+
+ * New Android app, using QML instead of Kivy
+ - Using Qt 5.15.7, PyQt 5.15.9
+ - This release still on python3.8
+ - Feature parity with Kivy
+ - Android Back button used throughout, for cancel/close/back
+ - Note: two topbar menus; tap wallet name for wallet menu, tap
+ network orb for application menu
+ - Note: long-press Receive/Send for list of payment requests/invoices
+ * Qt GUI improvements
+ - New onchain transaction creation flow, with configurable preview
+ - Various options have been moved to toolbars, where their effect
+ can be more directly observed.
+ * Privacy features:
+ - lightning: support for option scid_alias.
+ - Qt GUI: UTXO privacy analysis: this dialog displays all the
+ wallet transactions that are either parent of a UTXO, or can be
+ related to it through address reuse (Note that in the case of
+ address reuse, it does not display children transactions.)
+ - Coins tab: New menu that lets users easily spend a selection
+ of UTXOs into a new channel, or into a submarine swap (Qt GUI).
+ * Internal:
+ - Lightning invoices are regenerated every time routing hints are
+ deprecated due to liquidity changes.
+ - Script descriptors are used internally to sign transactions.
+
+
+# Release 4.3.4 - Copyright is Dubious (January 26, 2023)
+ * Lightning:
+ - make sending trampoline payments more reliable (5251e7f8)
+ - use different trampoline feature bits than eclair (#8141)
+ * invoice-handling: fix get_request_by_addr incorrectly mapping
+ addresses to request ids when an address was reused (#8113)
+ * fix a deadlock in wallet.py (52e2da3a)
+ * CLI: detect if daemon is already running (c7e2125f)
+ * add an AppStream metainfo.xml file for Linux packagers (#8149)
+ * payserver plugin:
+ -replaced vendored qrcode lib
+ -added tabs for on-chain and lightning invoices
+ -revamped html and javascript
+
+
+# Release 4.3.3 - (January 3, 2023)
+ * Lightning:
+ - fix handling failed HTLCs in gossip-based routing (#7995)
+ - fix LN cooperative-chan-close to witness v1 addr (#8012)
+ * PSBTs:
+ - never put ypub/zpub in psbts, only plain xpubs (#8036)
+ - for witness v0 txins, put both UTXO and WIT_UTXO in psbt (#8039)
+ * Hardware wallets:
+ - Trezor: optimize signing speed by not serializing tx (#8058)
+ - Ledger:
+ - modify plugin to support new bitcoin app v2.1.0 (#8041),
+ - added a deprecation warning when using Ledger HW.1 devices.
+ Ledger itself stopped supporting HW.1 some years ago, and it is
+ becoming a maintenance burden for us to keep supporting it.
+ Please migrate away from these devices. Support will be removed
+ in a future release.
+ * Binaries:
+ - tighten build system to only use source pkgs in more places
+ (#7999, #8000)
+ - Windows:
+ - use debian makensis instead of upstream windows exe (#8057)
+ - stop using debian sid, build missing dep instead (98d29cba)
+ - AppImage: fix failing to run on certain systems (#8011)
+ * commands:
+ - getinfo() to show if running in testnet mode (#8044)
+ - add a "convert_currency" command (for fiat FX rate) (#8091)
+ * Qt wizard: fix QR code not shown during 2fa wallet creation (#8071)
+ * rework Tor-socks-proxy detection to reduce Tor-log-spam (#7317)
+ * Android: add setting to enable debug logs (#7409)
+ * fix payserver (merchant) js for electrum 4.3 invoice api (0fc90e07)
+ * bip21: more robust handling of URIs that include a "lightning" key
+ (ac1d53f0, 2fd762c3, #8047)
+
+
+# Release 4.3.2 - (September 26, 2022)
+ * When creating new requests, reuse addresses of expired requests
+ (fixes #7927).
+ * Index requests by ID instead of receiving address. This affects the
+ following commands: get_request, get_invoice, list_requests,
+ list_invoices, delete_request, delete_invoice
+ * Trampoline routing: remember routes that have failed. Try other
+ routes instead of systematically raising tampoline fees.
+ * Fix sweep to_local output from channel backup (#7959)
+ * Harden build script for macOS binary: avoid using
+ precompiled wheels from PyPI for most packages (#7918)
+ * The Windows/AppImage/Android binaries are now built on debian using
+ the snapshot.debian.org archive instead of ubuntu. This should help
+ with historical reproducibility. (#7926)
+
+# Release 4.3.1 - (August 17, 2022)
+ * build: we now also distribute a "source-only"
+ Linux-packager-friendly tarball (d0de44a7, #7594), in addition
+ to the current "normal" tarball. The "source-only" tarball excludes
+ compiled locale files, generated protobuf files, and does not
+ vendor our runtime python dependencies (the packages/ folder).
+ * fix os.chmod when running in tmpfs on Linux (#7681)
+ * (Qt GUI) some improvements for high-DPI monitors (38881129)
+ * bring kivy request dialog more in-line with Qt (#7929)
+ * rm support of "legacy" (without static_remotekey) LN channels.
+ Opening these channels were never supported in a release version,
+ only during development prior to the first lightning-capable
+ release. Wallets with such channels will have to close them.
+ (1f403d1c, 7b8e257e)
+ * Qt: fix duplication of some OS notifications on onchain txs (#7943)
+ * fix multiple recent regressions:
+ - handle NotEnoughFunds when trying to pay LN invoice (#7920)
+ - handle NotEnoughFunds when trying to open LN channel (#7921)
+ - labels of payment requests were not propagated to
+ history/addresses (#7919)
+ - better default labels of outgoing txs (#7942)
+ - kivy: dust-valued requests could not be created for LN (#7928)
+ - when closing LN channels, future (timelocked) txs were not
+ shown in history (#7930)
+ - kivy: fix deleting "local" tx from history (#7933)
+ - kivy: fix paying amountless LN invoice (#7935)
+ - Qt: better handle unparseable URIs (#7941)
+
+
+# Release 4.3.0 - (August 5, 2022)
+
+ * This version introduces a set of UI modifications that simplify the
+ use of Lightning. The idea is to abstract payments from the payment
+ layer, and to suggest solutions when a lightning payment is hindered
+ by liquidity issues.
+ - Invoice unification: on-chain and lightning invoices have been
+ merged into a unique type of invoice, and the GUI has a single
+ 'create request' button. Unified invoices contain both a
+ lightning invoice and an onchain fallback address.
+ - The receive tab of the GUI can display, for each payment
+ request, a lightning invoice, a BIP21 URI, or an onchain
+ address. If the request is paid off-chain, the associated
+ on-chain address will be recycled in subsequent requests.
+ - The receive tab displays whether a payment can be received using
+ Lightning, given the current channel liquidity. If a payment
+ cannot be received, but may be received after a channel
+ rebalance or a submarine swap, the GUI will propose such an
+ operation.
+ - Similarly, if channels do not have enough liquidity to pay a
+ lightning invoice, the GUI will suggest available alternatives:
+ rebalance existing channels, open a new channel, perform a
+ submarine swap, or pay to the provided onchain fallback address.
+ - A single balance is shown in the GUI. A pie chart reflects how
+ that balance is distributed (on-chain, lightning, unconfirmed,
+ frozen, etc).
+ - The semantics of the wallet balance has been modified: only
+ incoming transactions are considered in the 'unconfirmed' part
+ of the balance. Indeed, if an outgoing transaction does not get
+ mined, that is not going to decrease the wallet balance. Thus,
+ change outputs of outgoing transactions are not subtracted from
+ the confirmed balance. (Before this change, the arithmetic
+ values of both incoming and outgoing transactions were added to
+ the unconfirmed balance, and could potentially cancel
+ each other.)
+
+ * In addition, the following new features are worth noting:
+ - support for the Blockstream Jade hardware wallet (#7633)
+ - support for LNURL-pay (LUD-06) (#7839)
+ - updated trampoline feature bit in invoices (#7801)
+ - the claim transactions of reverse swaps are not broadcast until
+ the parent transaction is confirmed. This can be overridden by
+ manually broadcasting the local transaction.
+ - the fee of submarine swap transactions can be bumped (#7724)
+ - better error handling for trampoline payments, which should
+ improve payment success rate (#7844)
+ - channel backups are removed automatically when the corresponding
+ channel is redeemed (#7513)
+
+
+# Release 4.2.2 - (May 27, 2022)
+ * Lightning:
+ - watching onchain outputs: significant perf. improvements (#7781)
+ - enforce relative order of some msgs during chan reestablishment,
+ lack of which can lead to unwanted force-closures (#7830)
+ - fix: in case of a force-close containing incoming HTLCs, we were
+ redeeming all HTLCs that we know the preimage for. This might
+ publish the preimage of an incomplete MPP. (1a5ef554, e74e9d8e)
+ * Hardware wallets:
+ - smarter pairing during sign_transaction (238619f1)
+ - keepkey: fix pairing with device using a workaround (#7779)
+ * fix AppImage failing to run on certain systems (#7784)
+ * fix "Automated BIP39 recovery" not scanning change paths (#7804)
+ * bypass network proxy for localhost electrum server (#3126)
+ * security fix: remove support of "file://" URIs from BIP70 payment
+ requests, which could be used to trigger "open()" on arbitrary files
+ (see https://github.com/spesmilo/electrum/security/advisories/GHSA-4fh4-hx35-r355)
+
+
+# Release 4.2.1 - (March 26, 2022)
+ * Binaries:
+ - Windows: we are dropping support for Windows 7. (#7728)
+ Version 4.2.0 already unintentionally broke compatibility with
+ Win7 and there is no easy way to restore and maintain support.
+ Existing users can keep using version 4.1.5 for now, but should
+ consider upgrading or changing their OS.
+ Win8.1 still works but only Win10 is regularly tested.
+ - bump bundled Python version (win, mac, appimage) to 3.9.11,
+ (android) to 3.8.13 (1bb7ef92, #7721)
+ (note these include a fix to an openssl DOS-vector CVE-2022-0778)
+ - windows: bump pyinstaller to 4.10 and wine to 7.0 (#7721)
+ * Kivy GUI:
+ - fix "Child Pays For Parent" not working on Android (#7723)
+ - revert to defaulting the UI language to English (25fee6a6)
+ * Qt GUI:
+ - macOS: fix opening "Preferences" segfaulting for some (#7725)
+ - more resilient startup: better error-handling and fallback (#7447)
+ * Library:
+ - fix LN error/warning message-handling, and fix regression that
+ errors during channel-open were not properly shown in GUI (a92dede4)
+ - during LN chan open, do not backup wallet automatically (#7733)
+ - Imported wallets: fix delete_address rm-ing too many txs (#7587)
+ - fix potential deadlock in wallet.py (d3476b6b)
+ * Hardware wallets:
+ - ledger: add progress indicator to sign_transaction (#7516)
+ * fix the "--portable" flag for AppImage, and for pip installs (#7732)
+
+
+# Release 4.2.0 - (March 16, 2022)
+ * The minimum python version was increased to 3.8 (#7661)
+ * Lightning:
+ - redesigned MPP splitting algorithm (#7202)
+ - trampoline: implement multi-trampoline MPP (#7623)
+ - implement option_shutdown_anysegwit, and allow dust limits
+ below 546 sat (#7542)
+ - implement option_channel_type (#7636)
+ - implement modern closing negotiation (#7586, #7680)
+ * improve support for "lightning:" URIs on all platforms (#7301)
+ * Qt GUI:
+ - add setting "show amounts with msat precision" (5891e039)
+ - add setting "add thousand separators to bitcoin amounts" (#7427)
+ * CLI/RPC:
+ - implement Unix sockets and make them the default (#7545, #7566)
+ - add "bumpfee" command (#7438)
+ * Kivy GUI:
+ - show network setup on first start before wallet creation (#7464)
+ - add "Child Pays For Parent" option (#7487)
+ - improved locale handling (22bb52d5, 7cb11ced, 4293d6ec)
+ * Hardware wallets:
+ - trezor: bump trezorlib to 0.13 (#7590)
+ - bitbox02: bump bitbox02 to 6.0, support send-to-taproot (#7693)
+ - ledger: support "Ledger Nano S Plus" (#7692)
+ * Library:
+ - added support for sighash types beside "ALL" (#7453)
+ - signmessage: also accept Trezor-type sigs for segwit addrs (#7668)
+ - network: make request timeout configurable (#7696)
+ - paytomany (onchain txout batching) now allows multiple max("!")
+ amounts with specified weights (#7492)
+ * Binary builds
+ - AppImage: changed base image from ubuntu 16.04 to 18.04 (5d0aa63a)
+ * migrated from Travis CI to Cirrus CI (#7431)
+ * Lots of other minor bugfixes and usability improvements.
+
+
+# Release 4.1.5 - (July 19, 2021)
+ * Builds/binaries:
+ - macOS: the .dmg binary should now be reproducible
+ * Kivy/Android: fix paying bip70 invoices (regression) (90579ccf)
+ * fix: payment requests not saved if process is killed (6a049d99)
+ * Lightning: improve payment success when using trampoline (3a7f5373)
+ * add support for signet test network (#7282)
+ * Qt GUI:
+ - allow restoring from SLIP39 seeds (#6917)
+ - rework QR code scanning on Windows and macOS (#7365)
+ - support smaller window sizes, decrease minimums (#7385)
+ * GUIs: add "funded or unused" filter option to Addresses tab (#5823)
+
+
+# Release 4.1.4 - (June 17, 2021)
+ * Kivy/Android: fix a regression where a non-LN wallet
+ could not open the settings (c49d6995)
+ * CLI/RPC: fix "close_wallet" command (#7348)
+
+
+# Release 4.1.3 - (June 16, 2021)
+ * Builds/binaries:
+ - Android: the binaries (APKs) should now be reproducible (#7263)
+ - AppImage: fix some startup issues by including libxcb deps (#7198)
+ * Lightning:
+ - smarter LN pathfinding (if trampoline is disabled):
+ - estimate liquidity in channels using previous attempts (#7152)
+ - consider inflight HTLCs and try to route around them (#7292)
+ - bugfix: add more safety checks to avoid "batch RBF" feature
+ merging LN funding txs (#7298)
+ - remove HTLC value upper limit of ~42 mBTC (#7328)
+ - Kivy GUI: implement freezing LN channels (11bb39ee)
+ * imported wallets: when enabling the "Use change addresses" option,
+ change will now be sent to a random unused imported address. (#7330)
+ As before, by default, change is sent back to the "from address".
+ * seed generation: make sure newly created electrum seeds don't have
+ correct bip39 checksum by chance (#6001)
+ * other minor fixes
+
+
+# Release 4.1.2 - (April 8, 2021)
+ * Qt GUI:
+ - fix some crashes when exiting (#6889)
+ - make sure pressing Ctrl-C always quits (c41cd4ae)
+ * Kivy GUI (Android):
+ - fix bug with scrollbar, again (#7155)
+ - 2fa wallets: fix making transactions (#7190)
+ - implement freezing addresses (#7178)
+ * Android: use more modern application launcher/icon (#7187)
+
+
+# Release 4.1.1 - (April 2, 2021)
+ * fix Qt crash with the swap dialog
+ * fix Kivy bug with scrollbar (#7155)
+ * fix localization issues (#7158 #4621)
+ * fix python crash with swaps (#7160)
+ * other minor fixes
+
+
+# Release 4.1.0 - Kangaroo (March 30, 2021)
+
+This version is our second major release with support for the
+Lightning Network. While our initial Lightning release was mostly
+about implementing the protocol, this release brings features that are
+specifically aimed at keeping Electrum lightweight and trustless,
+while avoiding single points of failure. Most of the features listed
+below are user-visible.
+ * The wallet creation wizard no longer asks for a seed type, and
+ creates segwit wallets with bech32 addresses. Older seed types can
+ still be created with the command line.
+ * Paid invoices (both incoming and outgoing) are automatically
+ removed from the send/receive lists of the GUI (one confirmation is
+ needed for onchain invoices). Once removed from the list, invoice
+ details can still be accessed from the transaction history. In Qt,
+ invoice lists have been renamed to 'Sending queue' and 'Receiving
+ queue'.
+ * Lightning:
+ - recoverable channels (see below)
+ - trampoline payments (see below)
+ - support multi-part-payment
+ - support upfront-shutdown-script
+ * Recoverable channels (option):
+ - Recovery data is added to the channel funding transaction using
+ an OP_RETURN. This makes it possible to recover a static backup
+ of the channel from the wallet seed. Please note that static
+ backups only allow users to request a force-close of the channel
+ with the remote node, so that funds not locked in HTLCs can be
+ recovered. This assumes that the remote node is still online, did
+ not lose its data, and accepts to force close the channel.
+ - This option is only available for standard wallets with an
+ Electrum seed. It is not available for hardware wallets, because
+ it requires a deterministic derivation of the nodeID. It is also
+ not available in watching-only wallets, for the same reason. If a
+ wallet can have recoverable channels but has an old nodeID, users
+ who want to use that feature need to close all their existing
+ channels, and to restore their wallet from seed.
+ - Channel recovery data uses 20 bytes (16 bytes of the remote
+ NodeID plus 4 magic bytes) and is encrypted so that only the
+ wallet that owns it can decrypt it. However, blockchain analysis
+ will be able to tell that the transaction was probably created by
+ Electrum.
+ - If the 'use recoverable channels' option is enabled, other nodes
+ cannot open a channel to Electrum.
+ - If a channel is force-closed, the information in the on-chain
+ backup is not sufficient to retrieve the funds in the to_local
+ output, in case the wallet is lost in a boating accident before
+ expiration of the CSV delay. For that reason, an additional
+ backup is presented to the user if they force-close a channel.
+ * Trampoline routing (option): Trampoline is a solution that allows
+ light clients to delegate path-finding on the Lightning Network, so
+ that they do not have to download the entire network
+ graph. Trampoline routing was originally proposed by Bastien
+ Teinturier and is used in the Phoenix wallet. Here is how
+ Trampoline works in Electrum:
+ - Trampoline is enabled by default, in order to prevent unwanted
+ download of the network gossip. If trampoline is disabled, the
+ gossip will be downloaded, regardless of the existence of
+ channels.
+ - Because there is no discovery mechanism for trampoline nodes, the
+ list of available trampolines is hardcoded in the client (it will
+ remain so until support for trampoline routing is announced in
+ gossip). 3 trampoline nodes are currently available on mainnet:
+ ACINQ, Electrum and Hodlister.
+ - If Trampoline is enabled:
+ - payments use trampoline routing.
+ - gossip is disabled.
+ - the wallet can only open channels with trampoline nodes.
+ - pre-existing channels with non-trampoline nodes are frozen for
+ sending.
+ - There are two types of trampoline payments: legacy and trampoline
+ end-to-end. Legacy payments are possible with any receiver, but
+ they offer less privacy than end-to-end trampoline
+ payments. Electrum decides whether to perform legacy or
+ end-to-end based on the features in the invoice:
+ - OPTION_TRAMPOLINE_ROUTING_OPT (bit 25) for Electrum
+ - OPTION_TRAMPOLINE_ROUTING_OPT_ECLAIR (bit 51) for Eclair/Phoenix
+ - When performing a legacy payment, Electrum will add a second
+ trampoline node to the route in order to protect the privacy of
+ the payer and payee. It will fall back to a single trampoline if
+ the two-trampoline strategy has failed for all trampolines.
+ (Note: two-trampoline payments are currently not possible if the
+ first trampoline is the ACINQ node, and is disabled for that
+ node.)
+ - Similar to Phoenix, the fee and CLTV delay are found by
+ trial-and-error. If there is a second trampoline in the route, we
+ use the same fee/CLTV for both. This trial-and-error is
+ temporary; the final specification should add fee information in
+ the failure messages, so that we will be able to better fine-tune
+ trampoline fees.
+ * Qt: The increase fee dialog now has advanced options, and offers
+ the choice between different RBF strategies.
+ * Watchtowers: The 'use_local_watchtower' feature is deprecated, and
+ it has been removed from the Qt GUI. The 'use_remote_watchtower'
+ setting has been renamed to 'use_watchtower'.
+ * Password unification (Android only): When the Android app is
+ started, the entered password is checked against all wallets in
+ the directory. If the test passes:
+ - all wallets are encrypted
+ - new wallets will use the unified password
+ - password updates are performed on all wallets
+ Whether the password is unified can be seen in the GUI: In the
+ 'Settings' dialog, the description for the password setting is
+ 'Change password for this wallet' if the password is not unified,
+ and becomes 'Change password' if password is unified.
+ * Submarine swaps are now available on kivy/android.
+ * Android PIN reset: If the password is unified, the PIN can be reset
+ by providing the password.
+ * Android: on-chain fees have been removed from the settings
+ dialog. Instead, the fee slider is shown to the user every time an
+ on-chain transaction will be performed (sending a payment, opening
+ a channel, initiating a submarine swap)
+ * BIP-0350: use bech32m for witness version 1+ addresses (4315fa43).
+ We have supported sending to any witness version since Electrum
+ 3.0, using BIP-0173 (bech32) addresses. BIP-0350 makes a breaking
+ change in address encoding, and recommends using a new encoding
+ (bech32m) for sending to witness version 1 and later.
+ * Block explorer: allow setting a custom URL in Qt GUI (#6965)
+
+
+# Release 4.0.9 - (Dec 18, 2020)
+ * fixes a regression introduced in 4.0.8, that prevents from
+ paying BIP70 invoices (#6859)
+ * reflect frozen channels and disconnected peers in the displayed
+ 'can send/can receive' amounts.
+
+# Release 4.0.8 - (Dec 17, 2020)
+ * fix decoding BIP21 URIs with uppercase schema (d40bedb2)
+ * psbt: put full derivation paths into PSBT by default (c8155129)
+ * invoices: allow address-reuse (#6609, #6852)
+ * A few other minor bugfixes.
+
+# Release 4.0.7 - (Dec 9, 2020)
+ * kivy: fix open channel with 'max' amount
+ * kivy: fix regression introduced in last release (a9fc440)
+ * other minor GUI fixes
+ * Dependencies: as part of adapting to new dnspython (#6828),
+ - python-ecdsa is no longer needed at all,
+ - cryptography is now required (min 2.6), the user can no
+ longer choose between cryptography and pycryptodomex
+
+# Release 4.0.6 - (Dec 4, 2020)
+ * Fix 'Max' button issue for submarine swaps button (#6770)
+ * Fix 'Max' button in kivy (#6169)
+ * Various fixes for Kivy/Android install wizard
+ * More robust account keypath for BitBox02 (#6766)
+
+# Release 4.0.5 - (Nov 18, 2020)
+ * Fix .dmg binary hanging on recently released macOS 11 Big Sur (#6461)
+ * Lightning:
+ - bugfix: during LN channel opening, if the client crashed at the
+ wrong moment, the channel might not get fully persisted to disk,
+ and would need manual console-tinkering to recover (#6656)
+ - Lightning is enabled by default. Electrum will not connect to
+ the Lightning Network until the user opens a channel. (#6639)
+ - smarter node recommendation (to open channels with) (#6705)
+ * user interface: some minor changes that aim to improve usability
+ * Ledger:
+ - fix enumerating devices with new bitcoin app (1.5.1) (b78cbcff)
+ - fix compat with HW.1 (200f547a)
+ * A few other minor bugfixes.
+
+# Release 4.0.4 - (Oct 15, 2020)
+ * PSBT: fix regression in 4.0.3 where UTXO data was not included in
+ QR codes (#6600)
+ * new feature: "Cancel tx" (#6641). The Qt/kivy GUI allows cancelling
+ an unconfirmed RBF tx by double-spending its inputs to self.
+ * Windows binary:
+ - fix some issues with QR scanning by building zbar ourselves (#6593)
+ - when using setup exe, also install a debug binary (#6603)
+ * Ledger: fix "The derivation path is unusual" warnings (#6512)
+ (needs Bitcoin app 1.4.8+ installed on device)
+ * A few other minor bugfixes and usability improvements.
+
+# Release 4.0.3 - (Sep 11, 2020)
+ * PSBT: restore compatibility with Bitcoin Core following CVE-2020-14199:
+ we now allow a PSBT input to have both UTXO and WITNESS_UTXO (#6429).
+ (PSBTs created since 4.0.1 already contained UTXO for segwit inputs)
+ * Hardware wallets:
+ - bitbox02: better multisig UX: implement get_soft_device_id (#6386)
+ - coldcard: fix "show address" for multisig (#6517)
+ - all: run all device communication on a dedicated thread (#6561).
+ This should resolve some threading issues.
+ * new feature: "Automated BIP39 recovery" (#6219, #6155)
+ When restoring from a BIP39 seed, add option to scan many known
+ derivation paths for history, and show them to user to choose from.
+ * show derivation path of keystores in Qt GUI Wallet>Information (#4700)
+ * fix "signtransaction" RPC command (#6502)
+ * Dependencies: pyaes is no longer needed (#6563)
+ * The tar.gz source dist now bundles make_libsecp256k1.sh, to help
+ users getting libsecp256k1 (#6323).
+ * A few other minor bugfixes and usability improvements.
+
+# Release 4.0.2 - (July 8, 2020)
+ - rm old corrupted non-bip70 invoices (#6345)
+ - other minor fixes
+
+# Release 4.0.1 - (July 3, 2020)
+ * Lightning Network support (experimental)
+ - Our implementation of Lightning relies on Electrum servers to
+ query channel states. Since servers can lie about the state of a
+ channel, users should either use a server that they trust, or
+ setup a private watchtower (see below). A watchtower is also
+ recommended for lightning wallets that remain offline for
+ extended periods of time (the default CSV 'to_self_delay' is 1
+ week). Please note that Electrum Personal Server (EPS) cannot be
+ used with lightning wallets, because channels funding addresses
+ are arbitrary.
+ - Lightning funds cannot be restored from seed. Instead, users need
+ to create static backups of their channels. Static backups cannot
+ be used to perform lightning transactions, they can only be used
+ to trigger a remote-force-close of a channel.
+ - Lightning-enabled wallet files must not be copied. Instead, a
+ backup of the wallet can be created from the Qt menu, and it will
+ contain static backups of all its channels. Backups can also be
+ exported for each channel (e.g. via QR code), and imported in
+ another wallet. Since backups are encrypted with a key derived
+ from the wallet's xpub, they can only be imported into another
+ instance of the same wallet, or a watch-only version of it. The
+ force-close is not triggered automatically when the backup is
+ imported; imported backups can live inside a wallet file.
+ - Lightning can be enabled in the GUI (Wallet>Information) or from
+ the CLI (init_lightning). Lightning is currently restricted to HD
+ p2wpkh wallets (including watch-only and hardware wallets). The
+ Qt GUI, CLI/RPC, and the kivy GUI (Android) all have LN support,
+ with feature-richness in that order.
+ - LN protocol details: dataloss_protect and static_remotekey are
+ required; varonion and payment_secret are implemented, MPP not yet.
+ Channels are not announced ('private'), forwarding is disabled.
+ We do not serve gossip queries, only consume them.
+ - Submarine swaps: the GUI integrates a service that offers
+ atomically exchanging on-chain and lightning bitcoins for a fee.
+ Electrum Technologies runs a central server for this, powered by
+ the Boltz backend.
+ - Watchtowers: Electrum can run a local watchtower (GUI setting),
+ or it can connect to a remote watchtower. A watchtower contains
+ pre-signed transactions and does not need your private keys. A
+ local watchtower will watch your channels whenever an Electrum
+ instance is running, without needing access to your wallet file.
+ An Electrum daemon can be configured to be used as a remote
+ watchtower by setting 'watchtower_address', 'watchtower_user' and
+ 'watchtower_password'.
+ * Partially Signed Bitcoin Transactions (PSBT, BIP-174) are supported
+ (#5721). The previous Electrum partial transaction format is no
+ longer supported, i.e. this is an incompatible change. Users should
+ make sure that all instances of Electrum they use to co-sign or
+ offline sign, are updated together.
+ * Hardware wallets: several fixes in general; notable changes:
+ - The BitBox02 is now supported (#5993)
+ - Multisig support for Coldcard (#5440)
+ - Compatibility with latest Trezor fw (#6064, #6198, #5692)
+ * Dependencies (see README for install instructions):
+ - libsecp256k1 is now required (previously optional). python-ecdsa
+ remains a dependency but it is now only used for DNSSEC.
+ - Added: either one of pycryptodomex or cryptography is now required,
+ mainly due to LN (previously pycryptodomex was optional, for fast AES)
+ - Removed: jsonrpclib-pelix, the JSON-RPC library used for CLI/daemon
+ * Qt GUI: several changes, notably:
+ - Separation between output selection and transaction finalization.
+ - Coin selection moved to the Coins tab, and it affects all txns,
+ e.g. RBF fee-bumping, LN channel opens, submarine swaps.
+ - Editable tx preview dialog that allows e.g. changing the locktime,
+ toggling RBF, and manual coinjoins.
+ * HTTP PayServer: The configuration of a bitcoin-accepting website
+ using Electrum has been simplified and requires fewer steps (see
+ documentation). The Payserver supports BIP70 and Lightning payments.
+ * Android:
+ - We now build two APKs, one for ARMv7 and one for ARMv8
+ - The kivy GUI now supports importing BIP39 seeds
+ - Each wallet on kivy now can have a separate generic password,
+ using which the wallet files are encrypted. An optional PIN,
+ shared among all wallets, can be added to get prompted for spends.
+ * The API of several CLI/RPC commands have changed, and several new
+ commands have been introduced (mainly for LN).
+ * Distributables:
+ - The .tar.gz source dist is now built reproducibly.
+ Relatedly, we no longer distribute a .zip sdist.
+ - The MacOS binary now conforms to macOS 10.15; it is notarized
+ by Apple. This required bumping the min macOS version to 10.13.
+ Startup times should now be faster on 10.15. (#6128, #6225)
+ * Transactions:
+ - we now grind low R for ECDSA signatures to match bitcoind (#5820)
+ * Lots and lots of other minor bugfixes and improvements.
+
+
+# Release 3.3.8 - (July 11, 2019)
+
+ * fix some bugs with recent bump fee (RBF) improvements (#5483, #5502)
+ * fix #5491: watch-only wallets could not bump fee in some cases
+ * appimage: URLs could not be opened on some desktop environments (#5425)
+ * faster tx signing for segwit inputs for really large txns (#5494)
+ * A few other minor bugfixes and usability improvements.
+
+
+# Release 3.3.7 - (July 3, 2019)
+
+ * The AppImage Linux x86_64 binary and the Windows setup.exe
+ (so now all Windows binaries) are now built reproducibly.
+ * Bump fee (RBF) improvements:
+ Implemented a new fee-bump strategy that can add new inputs,
+ so now any tx can be fee-bumped (d0a4366). The old strategy
+ was to decrease the value of outputs (starting with change).
+ We will now try the new strategy first, and only use the old
+ as a fallback (needed e.g. when spending "Max").
+ * CoinChooser improvements:
+ - more likely to construct txs without change (when possible)
+ - less likely to construct txs with really small change (e864fa5)
+ - will now only spend negative effective value coins when
+ beneficial for privacy (cb69aa8)
+ * fix long-standing bug that broke wallets with >65k addresses (#5366)
+ * Windows binaries: we now build the PyInstaller boot loader ourselves,
+ as this seems to reduce anti-virus false positives (1d0f679)
+ * Android: (fix) BIP70 payment requests could not be paid (#5376)
+ * Android: allow copy-pasting partial transactions from/to clipboard
+ * Fix a performance regression for large wallets (c6a54f0)
+ * Qt: fix some high DPI issues related to text fields (37809be)
+ * Trezor:
+ - allow bypassing "too old firmware" error (#5391)
+ - use only the Bridge to scan devices if it is available (#5420)
+ * hw wallets: (known issue) on Win10-1903, some hw devices
+ (that also have U2F functionality) can only be detected with
+ Administrator privileges. (see #5420 and #5437)
+ A workaround is to run as Admin, or for Trezor to install the Bridge.
+ * Several other minor bugfixes and usability improvements.
+
+
+# Release 3.3.6 - (May 16, 2019)
+
+ * qt: fix crash during 2FA wallet creation (#5334)
+ * fix synchronizer not to keep resubscribing to addresses of
+ already closed wallets (e415c0d9)
+ * fix removing addresses/keys from imported wallets (#4481)
+ * kivy: fix crash when aborting 2FA wallet creation (#5333)
+ * kivy: fix rare crash when changing exchange rate settings (#5329)
+ * A few other minor bugfixes and usability improvements.
+
+
+# Release 3.3.5 - (May 9, 2019)
+
+ * The logging system has been overhauled (#5296).
+ Logs can now also optionally be written to disk, disabled by default.
+ * Fix a bug in synchronizer (#5122) where client could get stuck.
+ Also, show the progress of history sync in the GUI. (#5319)
+ * fix Revealer in Windows and MacOS binaries (#5027)
+ * fiat rate providers:
+ - added CoinGecko.com and CoinCap.io
+ - BitcoinAverage now only provides historical exchange rates for
+ paying customers. Changed default provider to CoinGecko.com (#5188)
+ * hardware wallets:
+ - Ledger: Nano X is now recognized (#5140)
+ - KeepKey:
+ - device was not getting detected using Windows binary (#5165)
+ - support firmware 6.0.0+ (#5205)
+ - Trezor: implemented "seedless" mode (#5118)
+ * Coin Control in Qt: implemented freezing individual UTXOs
+ in addition to freezing addresses (#5152)
+ * TrustedCoin (2FA wallets):
+ - better error messages (#5184)
+ - longer signing timeout (#5221)
+ * Kivy:
+ - fix bug with local transactions (#5156)
+ - allow selecting fiat rate providers without historical data (#5162)
+ * fix CPFP: the fees already paid by the parent were not included in
+ the calculation, so it always overestimated (#5244)
+ * Testnet: there is now a warning when the client is started in
+ testnet mode as there were a number of reports of users getting
+ scammed through social engineering (#5295)
+ * CoinChooser: performance of creating transactions has been improved
+ significantly for large wallets. (d56917f4)
+ * Importing/sweeping WIF keys: stricter checks (#4638, #5290)
+ * Electrum protocol: the client's "user agent" has been changed from
+ "3.3.5" to "electrum/3.3.5". Other libraries connecting to servers
+ can consider not "spoofing" to be Electrum. (#5246)
+ * Several other minor bugfixes and usability improvements.
+
+
+# Release 3.3.4 - (February 13, 2019)
+
+ * AppImage: we now also distribute self-contained binaries for x86_64
+ Linux in the form of an AppImage (#5042). The Python interpreter,
+ PyQt5, libsecp256k1, PyCryptodomex, zbar, hidapi/libusb (including
+ hardware wallet libraries) are all bundled. Note that users of
+ hw wallets still need to set udev rules themselves.
+ * hw wallets: fix a regression during transaction signing that prompts
+ the user too many times for confirmations (commit 2729909)
+ * transactions now set nVersion to 2, to mimic Bitcoin Core
+ * fix Qt bug that made all hw wallets unusable on Windows 8.1 (#4960)
+ * fix bugs in wallet creation wizard that resulted in corrupted
+ wallets being created in rare cases (#5082, #5057)
+ * fix compatibility with Qt 5.12 (#5109)
+
+
+# Release 3.3.3 - (January 25, 2019)
+
+ * Do not expose users to server error messages (#4968)
+ * Notify users of new releases. Release announcements must be signed,
+ and they are verified byElectrum using a hardcoded Bitcoin address.
+ * Hardware wallet fixes (#4991, #4993, #5006)
+ * Display only QR code in QRcode Window
+ * Fixed code signing on MacOS
+ * Randomise locktime of transactions
+
+
+# Release 3.3.2 - (December 21, 2018)
+
+ * Fix Qt history export bug
+ * Improve network timeouts
+ * Prepend server transaction_broadcast error messages with
+ explanatory message. Render error messages as plain text.
+
+
+# Release 3.3.1 - (December 20, 2018)
+
+ * Qt: Fix invoices tab crash (#4941)
+ * Android: Minor GUI improvements
+
+
+# Release 3.3.0 - Hodler's Edition (December 19, 2018)
+
+ * The network layer has been rewritten using asyncio and aiorpcx.
+ In addition to easier maintenance, this makes the client
+ more robust against misbehaving servers.
+ * The minimum python version was increased to 3.6
+ * The blockchain headers and fork handling logic has been generalized.
+ Clients by default now follow chain based on most work, not length.
+ * New wallet creation defaults to native segwit (bech32).
+ * Segwit 2FA: TrustedCoin now supports native segwit p2wsh
+ two-factor wallets.
+ * RBF batching (opt-in): If the wallet has an unconfirmed RBF
+ transaction, new payments will be added to that transaction,
+ instead of creating new transactions.
+ * MacOS: support QR code scanner in binaries.
+ * Android APK:
+ - build using Google NDK instead of Crystax NDK
+ - target API 28
+ - do not use external storage (previously for block headers)
+ * hardware wallets:
+ - Coldcard now supports spending from p2wpkh-p2sh,
+ fixed p2pkh signing for fw 1.1.0
+ - Archos Safe-T mini: fix #4726 signing issue
+ - KeepKey: full segwit support
+ - Trezor: refactoring and compat with python-trezor 0.11
+ - Digital BitBox: support firmware v5.0.0
+ * fix bitcoin URI handling when app already running (#4796)
+ * Qt listings rewritten:
+ the History tab now uses QAbstractItemModel, the other tabs use
+ QStandardItemModel. Performance should be better for large wallets.
+ * Several other minor bugfixes and usability improvements.
+
+
+# Release 3.2.4 - (December 30, 2018)
+
+ * backport anti-phishing measures from master
+
+
+# Release 3.2.3 - (September 3, 2018)
+
+ * hardware wallet: the Safe-T mini from Archos is now supported.
+ * hardware wallet: the Coldcard from Coinkite is now supported.
+ * BIP39 seeds: if a seed extension (aka passphrase) contained
+ multiple consecutive whitespaces or leading/trailing whitespaces
+ then the derived addresses were not following spec. This has been
+ fixed, and affected should move their coins. The wizard will show a
+ warning in this case. (#4566)
+ * Revealer: the PRNG used has been changed (#4649)
+ * fix Linux distributables: 'typing' was not bundled, needed for python 3.4
+ * fix #4626: fix spending from segwit multisig wallets involving a Trezor
+ cosigner when using a custom derivation path
+ * fix #4491: on Android, if user had set "uBTC" as base unit, app crashed
+ * fix #4497: on Android, paying bip70 invoices from cold start did not work
+ * Several other minor bugfixes and usability improvements.
+
+
+# Release 3.2.2 - (July 2nd, 2018)
+
+ * Fix DNS resolution on Windows
+ * Fix websocket bug in daemon
+
+
+# Release 3.2.1 - (July 1st, 2018)
+
+ * fix Windows binaries: due to build process changes, the locale files
+ were not included; the language could not be changed from English
+ * fix Linux distributables: wordlists were not included (#4475)
+
+
+# Release 3.2.0 - Satoshi's Vision (June 30, 2018)
+
+ * If present, libsecp256k1 is used to speed up elliptic curve
+ operations. The library is bundled in the Windows, MacOS, and
+ Android binaries. On Linux, it needs to be installed separately.
+ * Two-factor authentication is available on Android. Note that this
+ will only provide additional security if one time passwords are
+ generated on a separate device.
+ * Semi-automated crash reporting is implemented for Android.
+ * Transactions that are dropped from the mempool are kept in the
+ wallet as 'local', and can be rebroadcast. Previously these
+ transactions were deleted from the wallet.
+ * The scriptSig and witness part of transaction inputs are no longer
+ parsed, unless actually needed. The wallet will no longer display
+ 'from' addresses corresponding to transaction inputs, except for
+ its own inputs.
+ * The partial transaction format has been incompatibly changed. This
+ was needed as for partial transactions the scriptSig/witness has to
+ be parsed, but for signed transactions we did not want to do the
+ parsing. Users should make sure that all instances of Electrum
+ they use to co-sign or offline sign, are updated together.
+ * Signing of partial transactions created with online imported
+ addresses wallets now supports significantly more
+ setups. Previously only online p2pkh address + offline WIF was
+ supported. Now the following setups are all supported:
+ - online {p2pkh, p2wpkh-p2sh, p2wpkh} address + offline WIF,
+ - online {p2pkh, p2wpkh-p2sh, p2wpkh} address + offline seed/xprv,
+ - online {p2sh, p2wsh-p2sh, p2wsh}-multisig address + offline seeds/xprvs
+ (potentially distributed among several different machines)
+ Note that for the online address + offline HD secret case, you need
+ the offline wallet to recognize the address (i.e. within gap
+ limit). Having an xpub on the online machine is still the
+ recommended setup, as this allows the online machine to generate
+ new addresses on demand.
+ * Segwit multisig for bip39 and hardware wallets is now enabled.
+ (both p2wsh-p2sh and native p2wsh)
+ * Ledger: offline signing for segwit inputs (#3302) This has already
+ worked for Trezor and Digital Bitbox. Offline segwit signing can be
+ combined with online imported addresses wallets.
+ * Added Revealer plugin. ( https://revealer.cc ) Revealer is a seed
+ phrase back-up solution. It allows you to create a cold, analog,
+ multi-factor backup of your wallet seeds, or of any arbitrary
+ secret. The Revealer utilizes a transparent plastic visual one time
+ pad.
+ * Fractional fee rates: the Qt GUI now displays fee rates with 0.1
+ sat/byte precision, and also allows this same resolution in the
+ Send tab.
+ * Hardware wallets: a "show address" button is now displayed in the
+ Receive tab of the Qt GUI. (#4316)
+ * Trezor One: implemented advanced/matrix recovery (#4329)
+ * Qt/Kivy: added "sat" as optional base unit.
+ * Kivy GUI: significant performance improvements when displaying
+ history and address list of large wallets; and transaction dialog
+ of large transactions.
+ * Windows: use dnspython to resolve dns instead of socket.getaddrinfo
+ (#4422)
+ * Importing minikeys: use uncompressed pubkey instead of compressed
+ (#4384)
+ * SPV proofs: check inner nodes not to be valid transactions (#4436)
+ * Qt GUI: there is now an optional "dark" theme (#4461)
+ * Several other minor bugfixes and usability improvements.
+
+
+# Release 3.1.3 - (April 16, 2018)
+
+ * Qt GUI: seed word auto-complete during restore
+ * Android: fix some crashes
+ * performance improvements (wallet, and Qt GUI)
+ * hardware wallets: show debug message during device scan
+ * Digital Bitbox: enabled BIP84 (p2wpkh) wallet creation
+ * add regtest support (via --regtest flag)
+ * other minor bugfixes and usability improvements
+
+# Release 3.1.2 - (March 28, 2018)
+
+ * Kivy/android: request PIN on startup
+ * Improve OSX build process
+ * Fix various bugs with hardware wallets
+ * Other minor bugfixes
+
+# Release 3.1.1 - (March 12, 2018)
+
+ * fix #4031: Trezor T support
+ * partial fix #4060: proxy and hardware wallet can't be used together
+ * fix #4039: can't set address labels
+ * fix crash related to coinbase transactions
+ * MacOS: use internal graphics card
+ * fix openalias related crashes
+ * speed-up capital gains calculations
+ * hw wallet encryption: re-prompt for passphrase if incorrect
+ * other minor fixes.
+
+
+
+# Release 3.1.0 - (March 5, 2018)
+
+ * Memory-pool based fee estimation. Dynamic fees can target a desired
+ depth in the memory pool. This feature is optional, and ETA-based
+ estimates from Bitcoin Core are still available. Note that miners
+ could exploit this feature, if they conspired and filled the memory
+ pool with expensive transactions that never get mined. However,
+ since the Electrum client already trusts an Electrum server with
+ fee estimates, activating this feature does not introduce any new
+ vulnerability. In addition, the client uses a hard threshold to
+ protect itself from servers sending excessive fee estimates. In
+ practice, ETA-based estimates have resulted in sticky fees, and
+ caused many users to overpay for transactions. Advanced users tend
+ to visit (and trust) websites that display memory-pool data in
+ order to set their fees.
+ * Capital gains: For each outgoing transaction, the difference
+ between the acquisition and liquidation prices of outgoing coins is
+ displayed in the wallet history. By default, historical exchange
+ rates are used to compute acquisition and liquidation prices. These
+ values can also be entered manually, in order to match the actual
+ price realized by the user. The order of liquidation of coins is
+ the natural order defined by the blockchain; this results in
+ capital gain values that are invariant to changes in the set of
+ addresses that are in the wallet. Any other ordering strategy (such
+ as FIFO, LIFO) would result in capital gain values that depend on
+ the presence of other addresses in the wallet.
+ * Local transactions: Transactions can be saved in the wallet without
+ being broadcast. The inputs of local transactions are considered as
+ spent, and their change outputs can be re-used in subsequent
+ transactions. This can be combined with cold storage, in order to
+ create several transactions before broadcasting them. Outgoing
+ transactions that have been removed from the memory pool are also
+ saved in the wallet, and can be broadcast again.
+ * Checkpoints: The initial download of a headers file was replaced
+ with hardcoded checkpoints. The wallet uses one checkpoint per
+ retargeting period. The headers for a retargeting period are
+ downloaded only if transactions need to be verified in this period.
+ * The 'privacy' and 'priority' coin selection policies have been
+ merged into one. Previously, the 'privacy' policy has been unusable
+ because it was was not prioritizing confirmed coins. The new policy
+ is similar to 'privacy', except that it de-prioritizes addresses
+ that have unconfirmed coins.
+ * The 'Send' tab of the Qt GUI displays how transaction fees are
+ computed from transaction size.
+ * The wallet history can be filtered by time interval.
+ * Replace-by-fee is enabled by default. Note that this might cause
+ some issues with wallets that do not display RBF transactions until
+ they are confirmed.
+ * Watching-only wallets and hardware wallets can be encrypted.
+ * Semi-automated crash reporting
+ * The SSL checkbox option was removed from the GUI.
+ * The Trezor T hardware wallet is now supported.
+ * BIP84: native segwit p2wpkh scripts for bip39 seeds and hardware
+ wallets can now be created when specifying a BIP84 derivation
+ path. This is usable with Trezor and Ledger.
+ * Windows: the binaries now include ZBar, and QR code scanning should work.
+ * The Wallet Import Format (WIF) for private keys that was extended in 3.0
+ is changed. Keys in the previous format can be imported, compatibility
+ is maintained. Newly exported keys will be serialized as
+ "script_type:original_wif_format_key".
+ * BIP32 master keys for testnet once again have different version bytes than
+ on mainnet. For the mainnet prefixes {x,y,Y,z,Z}|{pub,prv}, the
+ corresponding testnet prefixes are {t,u,U,v,V}|{pub,prv}.
+ More details and exact version bytes are specified at:
+ https://github.com/spesmilo/electrum-docs/blob/master/xpub_version_bytes.rst
+ Note that due to this change, testnet wallet files created with previous
+ versions of Electrum must be considered broken, and they need to be
+ recreated from seed words.
+ * A new version of the Electrum protocol is required by the client
+ (version 1.2). Servers using older versions of the protocol will
+ not be displayed in the GUI.
+
+
+# Release 3.0.6 :
+ * Fix transaction parsing bug #3788
+
+# Release 3.0.5 : (Security update)
+
+This is a follow-up to the 3.0.4 release, which did not completely fix
+issue #3374. Users should upgrade to 3.0.5.
+
+ * The JSONRPC interface is password protected
+ * JSONRPC commands are disabled if the GUI is running, except 'ping',
+ which is used to determine if a GUI is already running
+
+
+# Release 3.0.4 : (Security update)
+
+ * Fix a vulnerability caused by Cross-Origin Resource Sharing (CORS)
+ in the JSONRPC interface. Previous versions of Electrum are
+ vulnerable to port scanning and deanonimization attacks from
+ malicious websites. Wallets that are not password-protected are
+ vulnerable to theft.
+ * Bundle QR scanner with Android app
+ * Minor bug fixes
+
# Release 3.0.3
* Qt GUI: sweeping now uses the Send tab, allowing fees to be set
* Windows: if using the installer binary, there is now a separate shortcut
for "Electrum Testnet"
- * Digital Bitbox: added suport for p2sh-segwit
+ * Digital Bitbox: added support for p2sh-segwit
* OS notifications for incoming transactions
* better transaction size estimation:
- fees for segwit txns were somewhat underestimated (#3347)
@@ -33,7 +1745,7 @@
run "python3 setup.py install" in order to install the new
dependencies.
- * Segwit support:
+ * Segwit support:
- Native segwit scripts are supported using a new type of
seed. The version number for segwit seeds is 0x100. The install
@@ -91,6 +1803,10 @@
command line.
+# Release 2.9.4 (security update)
+ * Backport security fixes from 3.0.5 after vulnerability was
+ discovered in JSONRPC interface.
+
# Release 2.9.3
* fix configuration file issue #2719
* fix ledger signing of non-RBF transactions
@@ -230,7 +1946,7 @@
# Release 2.7.7
* Fix utf8 encoding bug with old wallet seeds (issue #1967)
- * Fix delete request from menu (isue #1968)
+ * Fix delete request from menu (issue #1968)
# Release 2.7.6
* Fixes a critical bug with imported private keys (issue #1966). Keys
@@ -593,7 +2309,7 @@
* New 'Receive' tab in the GUI:
- create and manage payment requests, with QR Codes
- the former 'Receive' tab was renamed to 'Addresses'
- - the former Point of Sale plugin is replaced by a resizeable
+ - the former Point of Sale plugin is replaced by a resizable
window that pops up if you click on the QR code
* The 'Send' tab in the Qt GUI supports transactions with multiple
@@ -616,7 +2332,7 @@
* The client accepts servers with a CA-signed SSL certificate.
- * ECIES encrypt/decrypt methods, availabe in the GUI and using
+ * ECIES encrypt/decrypt methods, available in the GUI and using
the command line:
encrypt
decrypt
@@ -689,7 +2405,7 @@ bugfixes: connection problems, transactions staying unverified
# Release 1.8.1
-* Notification option when receiving new tranactions
+* Notification option when receiving new transactions
* Confirm dialogue before sending large amounts
* Alternative datafile location for non-windows systems
* Fix offline wallet creation
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 000000000000..52239afb89e5
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,17 @@
+Two main ways to responsibly report security issues privately:
+
+1. (preferred) if you have a GitHub account, use the built-in
+ ["Report a vulnerability"](https://github.com/spesmilo/electrum/security/advisories/new)
+ flow, or
+2. you can send an email to the addresses listed below.
+ (Not for support. Support requests will be *ignored*.)
+
+If using email, please send any report to *all* emails listed here.
+
+| Name | Email | GPG fingerprint |
+|-------------|----------------------------------------|---------------------------------------------------|
+| ThomasV | thomasv [AT] electrum [DOT] org | 6694 D8DE 7BE8 EE56 31BE D950 2BD5 824B 7F94 70E6 |
+| SomberNight | somber.night [AT] protonmail [DOT] com | 4AD6 4339 DFA0 5E20 B3F6 AD51 E7B7 48CD AF5E 5ED9 |
+
+These GPG public keys can be found in the Electrum git repository,
+in the top-level `pubkeys` folder.
diff --git a/app.fil b/app.fil
deleted file mode 100644
index 835b6748f408..000000000000
--- a/app.fil
+++ /dev/null
@@ -1,29 +0,0 @@
-gui/qt/__init__.py
-gui/qt/main_window.py
-gui/qt/history_list.py
-gui/qt/contact_list.py
-gui/qt/invoice_list.py
-gui/qt/request_list.py
-gui/qt/installwizard.py
-gui/qt/network_dialog.py
-gui/qt/password_dialog.py
-gui/qt/util.py
-gui/qt/seed_dialog.py
-gui/qt/transaction_dialog.py
-gui/qt/address_dialog.py
-gui/qt/qrcodewidget.py
-gui/qt/qrtextedit.py
-gui/qt/qrwindow.py
-gui/kivy/main.kv
-gui/kivy/main_window.py
-gui/kivy/uix/dialogs/__init__.py
-gui/kivy/uix/dialogs/fee_dialog.py
-gui/kivy/uix/dialogs/installwizard.py
-gui/kivy/uix/dialogs/settings.py
-gui/kivy/uix/dialogs/wallets.py
-gui/kivy/uix/ui_screens/history.kv
-gui/kivy/uix/ui_screens/receive.kv
-gui/kivy/uix/ui_screens/send.kv
-plugins/labels/qt.py
-plugins/trezor/qt.py
-plugins/virtualkeyboard/qt.py
diff --git a/contrib/add_cosigner b/contrib/add_cosigner
new file mode 100755
index 000000000000..80f4263ec6a3
--- /dev/null
+++ b/contrib/add_cosigner
@@ -0,0 +1,77 @@
+#!/usr/bin/python3
+#
+# This script is part of the workflow for BUILDERs to reproduce and sign the
+# release binaries. (for builders who do not have sftp access to "electrum-downloads-airlock")
+#
+# env vars:
+# - SSHUSER
+#
+#
+# - BUILDER builds all binaries and checks they match the official releases
+# (using release.sh, and perhaps some manual steps)
+# - BUILDER creates a PR against https://github.com/spesmilo/electrum-signatures/
+# to add their sigs for a given release, which then gets merged
+# - SFTPUSER runs `$ SSHUSER=$SFTPUSER electrum/contrib/add_cosigner $BUILDER`
+# - SFTPUSER runs `$ electrum/contrib/make_download $WWW_DIR`
+# - $ (cd $WWW_DIR; git commit -a -m "add_cosigner"; git push)
+# - SFTPUSER runs `$ electrum-web/publish.sh $SFTPUSER`
+# - (for the website to be updated, both ThomasV and SomberNight needs to run publish.sh)
+
+import re
+import os
+import sys
+import importlib
+import importlib.util
+import subprocess
+
+
+if len(sys.argv) < 2:
+ print(f"usage: {os.path.basename(__file__)} ", file=sys.stderr)
+ sys.exit(1)
+
+# cd to project root
+os.chdir(os.path.dirname(os.path.dirname(__file__)))
+
+# load version.py; needlessly complicated alternative to "imp.load_source":
+version_spec = importlib.util.spec_from_file_location('version', 'electrum/version.py')
+version_module = importlib.util.module_from_spec(version_spec)
+version_spec.loader.exec_module(version_module)
+
+ELECTRUM_VERSION = version_module.ELECTRUM_VERSION
+print("version", ELECTRUM_VERSION)
+
+# GPG name of cosigner
+cosigner = sys.argv[1]
+
+version = version_win = version_mac = version_android = ELECTRUM_VERSION
+
+files = {
+ "tgz": f"Electrum-{version}.tar.gz",
+ "tgz_srconly": f"Electrum-sourceonly-{version}.tar.gz",
+ "appimage": f"electrum-{version}-x86_64.AppImage",
+ "mac": f"electrum-{version_mac}.dmg",
+ "win": f"electrum-{version_win}.exe",
+ "win_setup": f"electrum-{version_win}-setup.exe",
+ "win_portable": f"electrum-{version_win}-portable.exe",
+ "apk_arm64": f"Electrum-{version_android}-arm64-v8a-release.apk",
+ "apk_armeabi": f"Electrum-{version_android}-armeabi-v7a-release.apk",
+ "apk_x86_64": f"Electrum-{version_android}-x86_64-release.apk",
+}
+
+
+for shortname, filename in files.items():
+ path = f"dist/{filename}"
+ link = f"https://download.electrum.org/{version}/{filename}"
+ if not os.path.exists(path):
+ os.system(f"wget -q {link} -O {path}")
+ if not os.path.getsize(path):
+ raise Exception(path)
+ sig_name = f"{filename}.{cosigner}.asc"
+ sig_url = f"https://raw.githubusercontent.com/spesmilo/electrum-signatures/master/{version}/{filename}/{sig_name}"
+ sig_path = f"dist/{sig_name}"
+ os.system(f"wget -nc {sig_url} -O {sig_path}")
+ if os.system(f"gpg --verify {sig_path} {path}") != 0:
+ raise Exception(sig_name)
+
+print("Calling upload.sh now... This might take some time.")
+subprocess.check_output(["./contrib/upload.sh", ])
diff --git a/contrib/android/Dockerfile b/contrib/android/Dockerfile
new file mode 100644
index 000000000000..620f9cb5de14
--- /dev/null
+++ b/contrib/android/Dockerfile
@@ -0,0 +1,248 @@
+# based on https://github.com/kivy/python-for-android/blob/master/Dockerfile
+
+FROM debian:trixie@sha256:a3b5f4f0286249a124bfe9845b3aec0f88de32ff31dd8d7e1b945f9f98d116b0
+
+ENV DEBIAN_FRONTEND=noninteractive
+
+ENV ANDROID_HOME="/opt/android"
+
+# need ca-certificates before using snapshot packages
+RUN apt update -qq > /dev/null && apt install -qq --yes --no-install-recommends \
+ ca-certificates
+
+# pin the distro packages.
+COPY contrib/android/apt.sources.list /etc/apt/sources.list
+COPY contrib/android/apt.preferences /etc/apt/preferences.d/snapshot
+
+# configure locale
+RUN apt update -qq > /dev/null && apt install -qq --yes --no-install-recommends --allow-downgrades \
+ locales && \
+ locale-gen en_US.UTF-8
+ENV LANG="en_US.UTF-8" \
+ LANGUAGE="en_US.UTF-8" \
+ LC_ALL="en_US.UTF-8"
+
+RUN apt -y update -qq \
+ && apt -y install -qq --no-install-recommends --allow-downgrades \
+ curl \
+ wget \
+ unzip \
+ ca-certificates \
+ python3 \
+ && apt -y autoremove
+
+
+ENV ANDROID_NDK_HOME="${ANDROID_HOME}/android-ndk"
+ENV ANDROID_NDK_VERSION="28c"
+ENV ANDROID_NDK_HASH="dfb20d396df28ca02a8c708314b814a4d961dc9074f9a161932746f815aa552f"
+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
+ENV ANDROID_NDK_ARCHIVE="android-ndk-r${ANDROID_NDK_VERSION}-linux.zip"
+ENV ANDROID_NDK_DL_URL="https://dl.google.com/android/repository/${ANDROID_NDK_ARCHIVE}"
+
+# below disabled in favor of CI build download
+
+# download and install Android NDK
+RUN curl --location --progress-bar \
+ "${ANDROID_NDK_DL_URL}" \
+ --output "${ANDROID_NDK_ARCHIVE}" \
+ && echo "${ANDROID_NDK_HASH} ${ANDROID_NDK_ARCHIVE}" | sha256sum -c - \
+ && mkdir --parents "${ANDROID_NDK_HOME_V}" \
+ && unzip -q "${ANDROID_NDK_ARCHIVE}" -d "${ANDROID_HOME}" \
+ && ln -sfn "${ANDROID_NDK_HOME_V}" "${ANDROID_NDK_HOME}" \
+ && rm -rf "${ANDROID_NDK_ARCHIVE}"
+
+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="14742923"
+ENV ANDROID_SDK_HASH="04453066b540409d975c676d781da1477479dde3761310f1a7eb92a1dfb15af7"
+
+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}/cmdline-tools/bin/sdkmanager --sdk_root=${ANDROID_SDK_HOME}"
+
+# download and install Android SDK
+RUN curl --location --progress-bar \
+ "${ANDROID_SDK_TOOLS_DL_URL}" \
+ --output "${ANDROID_SDK_TOOLS_ARCHIVE}" \
+ && echo "${ANDROID_SDK_HASH} ${ANDROID_SDK_TOOLS_ARCHIVE}" | sha256sum -c - \
+ && mkdir --parents "${ANDROID_SDK_HOME}" \
+ && unzip -q "${ANDROID_SDK_TOOLS_ARCHIVE}" -d "${ANDROID_SDK_HOME}" \
+ && rm -rf "${ANDROID_SDK_TOOLS_ARCHIVE}"
+
+# update Android SDK, install Android API, Build Tools...
+RUN mkdir --parents "${ANDROID_SDK_HOME}/.android/" \
+ && echo '### User Sources for Android SDK Manager' \
+ > "${ANDROID_SDK_HOME}/.android/repositories.cfg"
+
+# download Java-17 (debian 13 only packages Java-21 and Java-25)
+# - we download the amd64 binaries from debian 12 repos
+# - we should try to upgrade to Java-21...
+# - the main blocker seems to be having to update Gradle (to a version compatible with Java-21)
+# - make_barcode_scanner.sh: markusfisch/{zxing-cpp, ...} pins old Gradle
+ENV JAVA_JRE_DL_URL="https://snapshot.debian.org/archive/debian/20260130T143028Z/pool/main/o/openjdk-17/openjdk-17-jre-headless_17.0.18+8-1~deb12u1_amd64.deb"
+ENV JAVA_JRE_ARCHIVE="openjdk-17-jre-headless.deb"
+ENV JAVA_JRE_HASH="5bc36cbb4e383dbea4168d57b5fd9b42375ec8837dd62a1d56677632c3c960e0"
+ENV JAVA_JDK_DL_URL="https://snapshot.debian.org/archive/debian/20260130T143028Z/pool/main/o/openjdk-17/openjdk-17-jdk-headless_17.0.18+8-1~deb12u1_amd64.deb"
+ENV JAVA_JDK_ARCHIVE="openjdk-17-jdk-headless.deb"
+ENV JAVA_JDK_HASH="8841044caa66860a71039342fe3c02b7853b61c518e05970e501faa215b1788a"
+RUN apt -y update -qq \
+ && apt -y install -qq --no-install-recommends \
+ ca-certificates-java \
+ java-common \
+ libcups2 \
+ libfontconfig1 \
+ liblcms2-2 \
+ libjpeg62-turbo \
+ libnss3 \
+ libasound2 \
+ libfreetype6 \
+ libharfbuzz0b \
+ libpcsclite1 \
+ && apt -y autoremove \
+ && cd /opt \
+ && curl --location --progress-bar "${JAVA_JRE_DL_URL}" --output "${JAVA_JRE_ARCHIVE}" \
+ && echo "${JAVA_JRE_HASH} ${JAVA_JRE_ARCHIVE}" | sha256sum -c - \
+ && dpkg -i "${JAVA_JRE_ARCHIVE}" \
+ && rm "${JAVA_JRE_ARCHIVE}" \
+ && curl --location --progress-bar "${JAVA_JDK_DL_URL}" --output "${JAVA_JDK_ARCHIVE}" \
+ && echo "${JAVA_JDK_HASH} ${JAVA_JDK_ARCHIVE}" | sha256sum -c - \
+ && dpkg -i "${JAVA_JDK_ARCHIVE}" \
+ && rm "${JAVA_JDK_ARCHIVE}"
+
+# accept Android licenses (JDK necessary!)
+RUN yes | ${ANDROID_SDK_MANAGER} --licenses > /dev/null
+
+
+ENV ANDROID_SDK_BUILD_TOOLS_MAJOR_V="35"
+ENV ANDROID_SDK_BUILD_TOOLS_VERSION="35.0.0"
+
+# download platforms, API, build tools
+RUN ${ANDROID_SDK_MANAGER} "platforms;android-${ANDROID_SDK_BUILD_TOOLS_MAJOR_V}" > /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}/cmdline-tools/bin/avdmanager"
+
+# download ANT
+ENV APACHE_ANT_VERSION="1.10.13"
+ENV APACHE_ANT_HASH="776be4a5704158f00ef3f23c0327546e38159389bc8f39abbfe114913f88bab1"
+ENV APACHE_ANT_ARCHIVE="apache-ant-${APACHE_ANT_VERSION}-bin.tar.gz"
+ENV APACHE_ANT_DL_URL="https://archive.apache.org/dist/ant/binaries/${APACHE_ANT_ARCHIVE}"
+ENV APACHE_ANT_HOME="${ANDROID_HOME}/apache-ant"
+ENV APACHE_ANT_HOME_V="${APACHE_ANT_HOME}-${APACHE_ANT_VERSION}"
+
+RUN curl --location --progress-bar \
+ "${APACHE_ANT_DL_URL}" \
+ --output "${APACHE_ANT_ARCHIVE}" \
+ && echo "${APACHE_ANT_HASH} ${APACHE_ANT_ARCHIVE}" | sha256sum -c - \
+ && tar -xf "${APACHE_ANT_ARCHIVE}" -C "${ANDROID_HOME}" \
+ && ln -sfn "${APACHE_ANT_HOME_V}" "${APACHE_ANT_HOME}" \
+ && rm -rf "${APACHE_ANT_ARCHIVE}"
+
+
+# install system/build dependencies
+# https://github.com/kivy/buildozer/blob/master/docs/source/installation.rst#android-on-ubuntu-2004-64bit
+RUN apt -y update -q \
+ && apt -y install -q --no-install-recommends --allow-downgrades \
+ wget \
+ lbzip2 \
+ patch \
+ sudo \
+ git \
+ zip \
+ unzip \
+ rsync \
+ build-essential \
+ ccache \
+ autoconf \
+ autopoint \
+ libtool \
+ pkg-config \
+ zlib1g-dev \
+ libncurses-dev \
+ cmake \
+ libffi-dev \
+ libssl-dev \
+ automake \
+ gettext \
+ libltdl-dev \
+ && apt -y autoremove \
+ && apt -y clean
+
+# cross compile deps for Qt6
+RUN apt -y update -qq \
+ && apt -y install -qq --no-install-recommends --allow-downgrades \
+ libopengl-dev \
+ libegl-dev \
+ && apt -y autoremove \
+ && apt -y clean
+
+
+# create new user to avoid using root; but with sudo access and no password for convenience.
+ARG UID=1000
+RUN useradd -u "$UID" -m -s /usr/bin/bash -d /home/user user
+RUN usermod -aG sudo user
+RUN echo "%sudo ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
+ENV HOME_DIR=/home/user
+ENV WORK_DIR="${HOME_DIR}/wspace"
+ENV PATH="${HOME_DIR}/.local/bin:${PATH}"
+WORKDIR ${WORK_DIR}
+RUN chown -R user ${WORK_DIR} ${ANDROID_SDK_HOME} /opt
+USER user
+
+# build cpython. FIXME we can't use the python3 from apt, as it is too new o.O
+# - p4a and buildozer require cython<3 (see https://github.com/kivy/python-for-android/issues/2919)
+# but the last such version, cython 0.29.37, can only be built by up to python 3.12
+ENV VENV_PYTHON_VERSION="3.12.12"
+ENV VENV_PY_VER_MAJOR="3.12"
+ENV VENV_PYTHON_HASH="487c908ddf4097a1b9ba859f25fe46d22ccaabfb335880faac305ac62bffb79b"
+RUN mkdir --parents "/opt/cpython/download" && cd "/opt/cpython/download" \
+ && wget "https://www.python.org/ftp/python/${VENV_PYTHON_VERSION}/Python-${VENV_PYTHON_VERSION}.tgz" \
+ && echo "${VENV_PYTHON_HASH} Python-${VENV_PYTHON_VERSION}.tgz" | sha256sum -c - \
+ && tar xf "Python-${VENV_PYTHON_VERSION}.tgz" -C "/opt/cpython/download" \
+ && cd "Python-${VENV_PYTHON_VERSION}" \
+ && mkdir "/opt/cpython/install" \
+ && ./configure \
+ --prefix="/opt/cpython/install" \
+ -q \
+ && make "-j$(nproc)" -s \
+ && make -s altinstall \
+ && ln -s "/opt/cpython/install/bin/python${VENV_PY_VER_MAJOR}" "/opt/cpython/install/bin/python3"
+RUN "/opt/cpython/install/bin/python3" -m ensurepip
+
+# venv, VIRTUAL_ENV is used by buildozer to indicate a venv environment
+ENV VIRTUAL_ENV=/opt/venv
+RUN "/opt/cpython/install/bin/python3" -m venv ${VIRTUAL_ENV}
+ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"
+
+COPY --chown=user contrib/deterministic-build/requirements-build-base.txt /opt/deterministic-build/
+COPY --chown=user contrib/deterministic-build/requirements-build-android.txt /opt/deterministic-build/
+RUN /opt/venv/bin/python3 -m pip install --no-build-isolation --no-dependencies \
+ -r /opt/deterministic-build/requirements-build-base.txt
+RUN /opt/venv/bin/python3 -m pip install --no-build-isolation --no-dependencies --no-binary :all: \
+ -r /opt/deterministic-build/requirements-build-android.txt
+
+# install buildozer
+ENV BUILDOZER_CHECKOUT_COMMIT="4403ecf445f10b5fbf7c74f4621bf2b922ad35b5"
+# ^ from branch electrum_20240930 (note: careful with force-pushing! see #8162)
+RUN cd /opt \
+ && git clone https://github.com/spesmilo/buildozer \
+ && cd buildozer \
+ && git checkout "${BUILDOZER_CHECKOUT_COMMIT}^{commit}" \
+ && /opt/venv/bin/python3 -m pip install --no-build-isolation --no-dependencies -e .
+
+# install python-for-android
+ENV P4A_CHECKOUT_COMMIT="1098be6964cfc2156959e435e81c2c50f8398586"
+# ^ from branch electrum_202602 (note: careful with force-pushing! see #8162)
+RUN cd /opt \
+ && git clone https://github.com/spesmilo/python-for-android \
+ && cd python-for-android \
+ && git checkout "${P4A_CHECKOUT_COMMIT}^{commit}" \
+ && /opt/venv/bin/python3 -m pip install --no-build-isolation --no-dependencies -e .
+
+# build env vars
+ENV USE_SDK_WRAPPER=1
+ENV GRADLE_OPTS="-Xmx1536M -Dorg.gradle.jvmargs='-Xmx1536M'"
+#ENV P4A_FULL_DEBUG=1
diff --git a/contrib/android/Makefile b/contrib/android/Makefile
new file mode 100644
index 000000000000..574779f749c8
--- /dev/null
+++ b/contrib/android/Makefile
@@ -0,0 +1,43 @@
+SHELL := /bin/bash
+PYTHON = python3
+
+# for reproducible builds
+export LC_ALL := C
+export TZ := UTC
+ifndef ELEC_APK_USE_CURRENT_TIME
+ export SOURCE_DATE_EPOCH := $(shell git log -1 --pretty=%ct)
+else
+ # p4a sets "private_version" based on SOURCE_DATE_EPOCH. "private_version" gets compiled into the apk,
+ # and is used at runtime to decide whether the already extracted project files in the app's datadir need updating.
+ # So, "private_version" needs to be reproducible, but it would be useful during development if it changed
+ # between subsequent builds (otherwise the new code won't be unpacked and used at runtime!).
+ # For this reason, for development purposes, we set SOURCE_DATE_EPOCH here to the current time.
+ # see https://github.com/kivy/python-for-android/blob/e8686e2104a553f05959cdaf7dd26867671fc8e6/pythonforandroid/bootstraps/common/build/build.py#L575-L587
+ export SOURCE_DATE_EPOCH := $(shell date +%s)
+endif
+export PYTHONHASHSEED := $(SOURCE_DATE_EPOCH)
+export BUILD_DATE := $(shell LC_ALL=C TZ=UTC date +'%b %e %Y' -d @$(SOURCE_DATE_EPOCH))
+export BUILD_TIME := $(shell LC_ALL=C TZ=UTC date +'%H:%M:%S' -d @$(SOURCE_DATE_EPOCH))
+
+
+.PHONY: apk clean
+
+prepare:
+ # running pre build setup
+ # copy electrum to main.py
+ @cp buildozer_$(ELEC_APK_GUI).spec ../../buildozer.spec
+ @cp ../../run_electrum ../../main.py
+apk:
+ @make prepare
+ @-cd ../..; buildozer android debug
+ @make clean
+release:
+ @make prepare
+ @-cd ../..; buildozer android release
+ @make clean
+clean:
+ # Cleaning up
+ # rename main.py to electrum
+ @-rm ../../main.py
+ # remove buildozer.spec
+ @-rm ../../buildozer.spec
diff --git a/contrib/android/Readme.md b/contrib/android/Readme.md
new file mode 100644
index 000000000000..c378bff20c10
--- /dev/null
+++ b/contrib/android/Readme.md
@@ -0,0 +1,221 @@
+# Qml GUI
+
+The Qml GUI is used with Electrum on Android devices, since Electrum 4.4.
+To generate an APK file, follow these instructions.
+
+(note: older versions of Electrum for Android used the "kivy" GUI)
+
+## Android binary with Docker
+
+✓ _These binaries should be reproducible, meaning you should be able to generate
+ binaries that match the official releases._
+
+- _Minimum supported target system (i.e. what end-users need): Android 6.0 (API 23)_
+
+This assumes an Ubuntu (x86_64) host, but it should not be too hard to adapt to another
+similar system.
+
+1. Install Docker
+
+ See [`contrib/docker_notes.md`](../docker_notes.md).
+
+ (worth reading even if you already have docker)
+
+2. Build binaries
+
+ The build script takes a few arguments. To see syntax, run it without providing any:
+ ```
+ $ ./build.sh
+ ```
+ For development, consider e.g. `$ ./build.sh qml arm64-v8a debug`
+
+ If you want reproducibility, try instead e.g.:
+ ```
+ $ ELECBUILD_COMMIT=HEAD ./build.sh qml all release-unsigned
+ ```
+
+3. The generated binary is in `./dist`.
+
+
+## Verifying reproducibility and comparing against official binary
+
+Every user can verify that the official binary was created from the source code in this
+repository.
+
+1. Build your own binary as described above.
+ Make sure you don't build in `debug` mode,
+ instead use either of `release` or `release-unsigned`.
+ If you build in `release` mode, the apk will be signed, which requires a keystore
+ that you need to create manually (see source of `make_apk.sh` for an example).
+2. Note that the binaries are not going to be byte-for-byte identical, as the official
+ release is signed by a keystore that only the project maintainers have.
+ You can use the `apkdiff.py` python script (written by the Signal developers) to compare
+ the two binaries.
+ ```
+ $ python3 contrib/android/apkdiff.py Electrum_apk_that_you_built.apk Electrum_apk_official_release.apk
+ ```
+ This should output `APKs match!`.
+
+
+## FAQ
+
+### I changed something but I don't see any differences on the phone. What did I do wrong?
+You probably need to clear the cache: `rm -rf .buildozer/android/platform/build-*/{build,dists}`
+
+Or more extreme, to nuke all build-related dirs: `rm -rf .buildozer_qml/ packages/ contrib/android/.cache/`
+
+Having a several months old build cache might also cause confusing build failures,
+in such a case, worth a try clearing it.
+
+
+### How do I deploy on connected phone for quick testing?
+Assuming `adb` is installed:
+```
+$ adb -d install -r dist/Electrum-*-arm64-v8a-debug.apk
+$ adb shell monkey -p org.electrum.electrum 1
+```
+Note `adb install` can take a `--user {userId}` option to install the app for a specific profile.
+Without that, the default is to install to *all* profiles.
+
+
+### How do I get an interactive shell inside docker?
+```
+$ docker run -it --rm \
+ -v $PWD:/home/user/wspace/electrum \
+ -v $PWD/.buildozer/.gradle:/home/user/.gradle \
+ --workdir /home/user/wspace/electrum \
+ electrum-android-builder-img
+```
+
+
+### How do I get more verbose logs for the build?
+See `log_level` in `buildozer.spec`
+
+
+### How can I see logs at runtime?
+This should work OK for most scenarios:
+```
+adb logcat | grep python
+```
+Better `grep` but fragile because of `cut`:
+```
+adb logcat | grep -F "`adb shell ps | grep org.electrum.electrum | cut -c14-19`"
+```
+
+
+### The Qml GUI can be run directly on Linux Desktop. How?
+Install requirements:
+```
+python3 -m pip install ".[qml_gui]"
+```
+
+Run electrum with the `-g` switch: `electrum -g qml`
+
+Notes:
+
+- Installing these deps from your OS package manager should also work,
+ except many don't distribute pyqt6 yet.
+ For pyqt5 on debian-based distros, this used to look like this:
+ ```
+ sudo apt-get install python3-pyqt5 python3-pyqt5.qtquick python3-pyqt5.qtmultimedia
+ sudo apt-get install python3-pil
+ sudo apt-get install qml-module-qtquick-controls2 qml-module-qtquick-layouts \
+ qml-module-qtquick-window2 qml-module-qtmultimedia \
+ libqt5multimedia5-plugins qml-module-qt-labs-folderlistmodel
+ ```
+
+
+### debug vs release build
+If you just follow the instructions above, you will build the apk
+in debug mode. The most notable difference is that the apk will be
+signed using a debug keystore. If you are planning to upload
+what you build to e.g. the Play Store, you should create your own
+keystore, back it up safely, and run `./build.sh` in `release` mode.
+
+See e.g. [kivy wiki](https://github.com/kivy/kivy/wiki/Creating-a-Release-APK)
+and [android dev docs](https://developer.android.com/studio/build/building-cmdline#sign_cmdline).
+
+### Access datadir on Android from desktop (e.g. to copy wallet file)
+Note that this only works for debug builds! Otherwise the security model
+of Android does not let you access the internal storage of an app without root.
+(See [this](https://stackoverflow.com/q/9017073))
+To pull a file:
+```
+$ adb shell
+adb$ run-as org.electrum.electrum ls /data/data/org.electrum.electrum/files/data
+adb$ exit
+$ adb exec-out run-as org.electrum.electrum cat /data/data/org.electrum.electrum/files/data/wallets/my_wallet > my_wallet
+```
+To push a file:
+```
+$ adb push ~/wspace/tmp/my_wallet /data/local/tmp
+$ adb shell
+adb$ ls -la /data/local/tmp
+adb$ run-as org.electrum.testnet.electrum cp /data/local/tmp/my_wallet /data/data/org.electrum.testnet.electrum/files/data/testnet/wallets/
+adb$ run-as org.electrum.testnet.electrum chmod -R 700 /data/data/org.electrum.testnet.electrum/files/data/testnet/wallets
+adb$ run-as org.electrum.testnet.electrum chmod -R u-x,u+X /data/data/org.electrum.testnet.electrum/files/data/testnet/wallets
+adb$ rm /data/local/tmp/my_wallet
+```
+
+Or use Android Studio: "Device File Explorer", which can download/upload data directly from device (via adb).
+
+#### Device with multiple user profiles
+
+There are further complications if using an Android device
+[with multiple user profiles](https://source.android.com/docs/devices/admin/multi-user-testing)
+(typical for GrapheneOS/etc).
+
+Run `$ adb shell pm list users` to get a list of all existing users, and take note of the user ids.
+
+Instead of `/data/data/{app.path}`, private app data is stored at `/data/user/{userId}/{app.path}`.
+
+Further, instead of `adb$ run-as org.electrum.electrum`,
+you need `adb$ run-as org.electrum.electrum --user {userId}`.
+
+### How to investigate diff between binaries if reproducibility fails?
+```
+cd dist/
+unzip Electrum-*.apk1 -d apk1
+mkdir apk1/assets/private_mp3/
+tar -xzvf apk1/assets/private.tar --directory apk1/assets/private_mp3/
+mkdir apk1/lib/_libpybundle/
+tar -xzvf apk1/lib/*/libpybundle.so --directory apk1/lib/_libpybundle/
+
+unzip Electrum-*.apk2 -d apk2
+mkdir apk2/assets/private_mp3/
+tar -xzvf apk2/assets/private.tar --directory apk2/assets/private_mp3/
+mkdir apk2/lib/_libpybundle/
+tar -xzvf apk2/lib/*/libpybundle.so --directory apk2/lib/_libpybundle/
+
+sudo chown --recursive "$(id -u -n)":"$(id -u -n)" apk1/ apk2/
+chmod -R +Xr apk1/ apk2/
+
+unzip apk1/lib/_libpybundle/_python_bundle/stdlib.zip -d apk1/lib/_libpybundle/_python_bundle/stdlib
+unzip apk2/lib/_libpybundle/_python_bundle/stdlib.zip -d apk2/lib/_libpybundle/_python_bundle/stdlib
+
+sudo chown --recursive "$(id -u -n)":"$(id -u -n)" apk1/ apk2/
+chmod -R +Xr apk1/ apk2/
+$(cd apk1; find -type f -exec sha256sum '{}' \; > ./../sha256sum1)
+$(cd apk2; find -type f -exec sha256sum '{}' \; > ./../sha256sum2)
+diff sha256sum1 sha256sum2 > d
+cat d
+```
+
+### How to install apks built by the CI on my phone?
+
+The CI (GitHub Actions) builds apks on a nightly schedule.
+See the [`builds` workflow](https://github.com/spesmilo/electrum/actions/workflows/builds.yml).
+Open the run of interest and download the `electrum-android-*` artifact.
+The apk is built in `debug` mode, and is signed using an ephemeral RSA key.
+
+For tech demo purposes, you can directly install this apk on your phone.
+However, if you already have electrum installed on your phone, Android's TOFU signing model
+will not let you upgrade that to the CI apk due to mismatching signing keys. As the CI key
+is ephemeral, it is not even possible to upgrade from an older CI apk to a newer CI apk.
+
+However, it is possible to resign the apk manually with one's own key, using
+e.g. [`apksigner`](https://developer.android.com/studio/command-line/apksigner),
+mutating the apk in place, after which it should be possible to upgrade:
+```
+apksigner sign --ks ~/wspace/electrum/contrib/android/android_debug.keystore Electrum-*-arm64-v8a-debug.apk
+```
diff --git a/contrib/android/apkdiff.py b/contrib/android/apkdiff.py
new file mode 100755
index 000000000000..da050f62d6dd
--- /dev/null
+++ b/contrib/android/apkdiff.py
@@ -0,0 +1,92 @@
+#! /usr/bin/env python3
+# from https://github.com/signalapp/Signal-Android/blob/2029ea378f249a70983c1fc3d55b9a63588bc06c/reproducible-builds/apkdiff/apkdiff.py
+
+import sys
+from zipfile import ZipFile
+
+
+# FIXME it is possible to hide data in the apk signing block - and then the application
+# can introspect itself at runtime and access that, even execute it as code... :/
+# see https://source.android.com/docs/security/features/apksigning/v2#apk-signing-block
+# https://android.izzysoft.de/articles/named/iod-scan-apkchecks
+# https://github.com/obfusk/sigblock-code-poc
+# I think if the app did this kind of introspection, that should be caught by code review,
+# but still, note that with this current diff script it is possible to smuggle data in the apk.
+class ApkDiff:
+ IGNORE_FILES = ["META-INF/MANIFEST.MF", "META-INF/CERT.RSA", "META-INF/CERT.SF"]
+
+ def compare(self, sourceApk, destinationApk) -> bool:
+ sourceZip = ZipFile(sourceApk, 'r')
+ destinationZip = ZipFile(destinationApk, 'r')
+
+ if self.compareManifests(sourceZip, destinationZip) and self.compareEntries(sourceZip, destinationZip):
+ print("APKs match!")
+ return True
+ else:
+ print("APKs don't match!")
+ return False
+
+ def compareManifests(self, sourceZip, destinationZip):
+ sourceEntrySortedList = sorted(sourceZip.namelist())
+ destinationEntrySortedList = sorted(destinationZip.namelist())
+
+ for ignoreFile in self.IGNORE_FILES:
+ while ignoreFile in sourceEntrySortedList: sourceEntrySortedList.remove(ignoreFile)
+ while ignoreFile in destinationEntrySortedList: destinationEntrySortedList.remove(ignoreFile)
+
+ if len(sourceEntrySortedList) != len(destinationEntrySortedList):
+ print("Manifest lengths differ!")
+
+ for (sourceEntryName, destinationEntryName) in zip(sourceEntrySortedList, destinationEntrySortedList):
+ if sourceEntryName != destinationEntryName:
+ print("Sorted manifests don't match, %s vs %s" % (sourceEntryName, destinationEntryName))
+ return False
+
+ return True
+
+ def compareEntries(self, sourceZip, destinationZip):
+ sourceInfoList = list(filter(lambda sourceInfo: sourceInfo.filename not in self.IGNORE_FILES, sourceZip.infolist()))
+ destinationInfoList = list(filter(lambda destinationInfo: destinationInfo.filename not in self.IGNORE_FILES, destinationZip.infolist()))
+
+ if len(sourceInfoList) != len(destinationInfoList):
+ print("APK info lists of different length!")
+ return False
+
+ for sourceEntryInfo in sourceInfoList:
+ for destinationEntryInfo in list(destinationInfoList):
+ if sourceEntryInfo.filename == destinationEntryInfo.filename:
+ sourceEntry = sourceZip.open(sourceEntryInfo, 'r')
+ destinationEntry = destinationZip.open(destinationEntryInfo, 'r')
+
+ if not self.compareFiles(sourceEntry, destinationEntry):
+ print("APK entry %s does not match %s!" % (sourceEntryInfo.filename, destinationEntryInfo.filename))
+ return False
+
+ destinationInfoList.remove(destinationEntryInfo)
+ break
+
+ return True
+
+ def compareFiles(self, sourceFile, destinationFile):
+ sourceChunk = sourceFile.read(1024)
+ destinationChunk = destinationFile.read(1024)
+
+ while sourceChunk != b"" or destinationChunk != b"":
+ if sourceChunk != destinationChunk:
+ return False
+
+ sourceChunk = sourceFile.read(1024)
+ destinationChunk = destinationFile.read(1024)
+
+ return True
+
+if __name__ == '__main__':
+ if len(sys.argv) != 3:
+ print("Usage: apkdiff ")
+ sys.exit(1)
+
+ match = ApkDiff().compare(sys.argv[1], sys.argv[2])
+ if match:
+ sys.exit(0)
+ else:
+ sys.exit(1)
diff --git a/contrib/android/apt.preferences b/contrib/android/apt.preferences
new file mode 100644
index 000000000000..d861cd83d960
--- /dev/null
+++ b/contrib/android/apt.preferences
@@ -0,0 +1,3 @@
+Package: *
+Pin: origin "snapshot.debian.org"
+Pin-Priority: 1001
diff --git a/contrib/android/apt.sources.list b/contrib/android/apt.sources.list
new file mode 100644
index 000000000000..db248cc0e470
--- /dev/null
+++ b/contrib/android/apt.sources.list
@@ -0,0 +1,2 @@
+deb https://snapshot.debian.org/archive/debian/20260129T082333Z/ trixie main
+deb-src https://snapshot.debian.org/archive/debian/20260129T082333Z/ trixie main
diff --git a/contrib/android/bitcoin_intent.xml b/contrib/android/bitcoin_intent.xml
new file mode 100644
index 000000000000..3592e45eb764
--- /dev/null
+++ b/contrib/android/bitcoin_intent.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/contrib/android/build.sh b/contrib/android/build.sh
new file mode 100755
index 000000000000..a156838eb5c5
--- /dev/null
+++ b/contrib/android/build.sh
@@ -0,0 +1,97 @@
+#!/bin/bash
+#
+# env vars:
+# - ELECBUILD_NOCACHE: if set, forces rebuild of docker image
+# - ELECBUILD_COMMIT: if set, do a fresh clone and git checkout
+
+set -e
+
+PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/../.."
+PROJECT_ROOT_OR_FRESHCLONE_ROOT="$PROJECT_ROOT"
+CONTRIB="$PROJECT_ROOT/contrib"
+CONTRIB_ANDROID="$CONTRIB/android"
+DISTDIR="$PROJECT_ROOT/dist"
+BUILD_UID=$(/usr/bin/stat -c %u "$PROJECT_ROOT")
+
+. "$CONTRIB"/build_tools_util.sh
+
+# check arguments
+if [[ -n "$3" \
+ && ( "$1" == "qml" ) \
+ && ( "$2" == "all" || "$2" == "armeabi-v7a" || "$2" == "arm64-v8a" || "$2" == "x86" || "$2" == "x86_64" ) \
+ && ( "$3" == "debug" || "$3" == "release" || "$3" == "release-unsigned" ) ]] ; then
+ info "arguments $1 $2 $3"
+else
+ fail "usage: build.sh "
+ exit 1
+fi
+
+# create symlink
+rm -f ${PROJECT_ROOT}/.buildozer
+mkdir -p "${PROJECT_ROOT}/.buildozer_$1"
+ln -s ".buildozer_$1" ${PROJECT_ROOT}/.buildozer
+
+DOCKER_BUILD_FLAGS=""
+if [ ! -z "$ELECBUILD_NOCACHE" ] ; then
+ info "ELECBUILD_NOCACHE is set. forcing rebuild of docker image."
+ DOCKER_BUILD_FLAGS="--pull --no-cache"
+fi
+
+if [ -z "$ELECBUILD_COMMIT" ] ; then # local dev build
+ DOCKER_BUILD_FLAGS="$DOCKER_BUILD_FLAGS --build-arg UID=$BUILD_UID"
+fi
+
+info "building docker image."
+docker build \
+ $DOCKER_BUILD_FLAGS \
+ -t electrum-android-builder-img \
+ --file "$CONTRIB_ANDROID/Dockerfile" \
+ "$PROJECT_ROOT"
+
+# maybe do fresh clone
+if [ ! -z "$ELECBUILD_COMMIT" ] ; then
+ info "ELECBUILD_COMMIT=$ELECBUILD_COMMIT. doing fresh clone and git checkout."
+ FRESH_CLONE=${FRESH_CLONE:-"/tmp/electrum_build/android/fresh_clone/electrum"}
+ rm -rf "$FRESH_CLONE" 2>/dev/null || ( info "we need sudo to rm prev FRESH_CLONE." && sudo rm -rf "$FRESH_CLONE" )
+ umask 0022
+ git clone "$PROJECT_ROOT" "$FRESH_CLONE"
+ cd "$FRESH_CLONE"
+ git checkout "$ELECBUILD_COMMIT"
+ PROJECT_ROOT_OR_FRESHCLONE_ROOT="$FRESH_CLONE"
+else
+ info "not doing fresh clone."
+fi
+
+DOCKER_RUN_FLAGS=""
+if [[ "$3" == "release" ]] ; then
+ info "'release' mode selected. mounting ~/.keystore inside container."
+ DOCKER_RUN_FLAGS="-v $HOME/.keystore:/home/user/.keystore"
+fi
+if sh -c ": >/dev/tty" >/dev/null 2>/dev/null; then
+ info "/dev/tty is available and usable"
+ DOCKER_RUN_FLAGS="$DOCKER_RUN_FLAGS -it"
+fi
+
+info "building binary..."
+mkdir --parents "$PROJECT_ROOT_OR_FRESHCLONE_ROOT"/.buildozer/.gradle
+# check uid and maybe chown. see #8261
+if [ ! -z "$ELECBUILD_COMMIT" ] ; then # fresh clone (reproducible build)
+ if [ $(id -u) != "1000" ] || [ $(id -g) != "1000" ] ; then
+ info "need to chown -R FRESH_CLONE dir. prompting for sudo."
+ sudo chown -R 1000:1000 "$FRESH_CLONE"
+ fi
+fi
+docker run --rm \
+ --name electrum-android-builder-cont \
+ -v "$PROJECT_ROOT_OR_FRESHCLONE_ROOT":/home/user/wspace/electrum \
+ -v "$PROJECT_ROOT_OR_FRESHCLONE_ROOT"/.buildozer/.gradle:/home/user/.gradle \
+ $DOCKER_RUN_FLAGS \
+ --workdir /home/user/wspace/electrum \
+ electrum-android-builder-img \
+ ./contrib/android/make_apk.sh "$@"
+
+# make sure resulting binary location is independent of fresh_clone
+if [ ! -z "$ELECBUILD_COMMIT" ] ; then
+ mkdir --parents "$DISTDIR/"
+ cp -f "$FRESH_CLONE/dist"/* "$DISTDIR/"
+fi
diff --git a/contrib/android/buildozer_qml.spec b/contrib/android/buildozer_qml.spec
new file mode 100644
index 000000000000..a73fdc5b8aa0
--- /dev/null
+++ b/contrib/android/buildozer_qml.spec
@@ -0,0 +1,315 @@
+[app]
+
+# (str) Title of your application
+title = Electrum
+
+# (str) Package name
+package.name = Electrum
+
+# (str) Package domain (needed for android/ios packaging)
+package.domain = org.electrum
+
+# (str) Source code where the main.py live
+source.dir = .
+
+# (list) Source files to include (let empty to include all the files)
+source.include_exts = py,png,jpg,qml,qmltypes,ttf,txt,gif,pem,mo,json,csv,so,svg
+
+# (list) Source files to exclude (let empty to not exclude anything)
+source.exclude_exts = spec
+
+# (list) List of directory to exclude (let empty to not exclude anything)
+source.exclude_dirs =
+ bin,
+ build,
+ dist,
+ contrib,
+ env,
+ tests,
+ fastlane,
+ electrum/www,
+ electrum/scripts,
+ electrum/utils,
+ electrum/gui/qt,
+ electrum/plugins/audio_modem,
+ electrum/plugins/bitbox02,
+ electrum/plugins/coldcard,
+ electrum/plugins/digitalbitbox,
+ electrum/plugins/jade,
+ electrum/plugins/keepkey,
+ electrum/plugins/ledger,
+ electrum/plugins/nwc,
+ electrum/plugins/payserver,
+ electrum/plugins/revealer,
+ electrum/plugins/safe_t,
+ electrum/plugins/swapserver,
+ electrum/plugins/timelock_recovery,
+ electrum/plugins/trezor,
+ electrum/plugins/watchtower,
+ packages/qdarkstyle,
+ packages/qtpy,
+ packages/bin,
+ packages/share,
+ packages/pkg_resources,
+ packages/setuptools
+
+# (list) List of exclusions using pattern matching
+source.exclude_patterns = Makefile,setup*,
+ # not reproducible:
+ packages/aiohttp-*.dist-info/*,
+ packages/frozenlist-*.dist-info/*
+
+# (str) Application versioning (method 1)
+version.regex = ELECTRUM_VERSION = '(.*)'
+version.filename = %(source.dir)s/electrum/version.py
+
+# (str) Application versioning (method 2)
+#version = 1.9.8
+
+# (list) Application requirements
+# note: versions and hashes are pinned in ./p4a_recipes/*
+requirements =
+ hostpython3,
+ python3,
+ android,
+ openssl,
+ plyer,
+ libffi,
+ libsecp256k1,
+ pycryptodomex,
+ pyqt6sip,
+ pyqt6,
+ libzbar
+
+# (str) Presplash of the application
+presplash.filename = %(source.dir)s/electrum/gui/icons/electrum_presplash.png
+
+# (str) Icon of the application
+icon.filename = %(source.dir)s/electrum/gui/icons/android_electrum_icon_legacy.png
+icon.adaptive_foreground.filename = %(source.dir)s/electrum/gui/icons/android_electrum_icon_foreground.png
+icon.adaptive_background.filename = %(source.dir)s/electrum/gui/icons/android_electrum_icon_background.png
+
+# (str) Supported orientation (one of landscape, portrait or all)
+orientation = portrait
+
+# (bool) Indicate if the application should be fullscreen or not
+fullscreen = False
+
+
+#
+# Android specific
+#
+
+# (list) Permissions
+android.permissions = INTERNET, CAMERA, WRITE_EXTERNAL_STORAGE, POST_NOTIFICATIONS, USE_BIOMETRIC
+
+# (int) Android API to use (compileSdkVersion)
+# note: when changing, Dockerfile also needs to be changed to install corresponding build tools
+android.api = 35
+
+# (int) Android targetSdkVersion
+android.target_sdk_version = 35
+
+# (int) Minimum API required. You will need to set the android.ndk_api to be as low as this value.
+android.minapi = 26
+
+# (str) Android NDK version to use
+android.ndk = 28c
+
+# (int) Android NDK API to use (optional). This is the minimum API your app will support.
+android.ndk_api = 26
+
+# (bool) Use --private data storage (True) or --dir public storage (False)
+#android.private_storage = True
+
+# (str) Android NDK directory (if empty, it will be automatically downloaded.)
+android.ndk_path = /opt/android/android-ndk
+
+# (str) Android SDK directory (if empty, it will be automatically downloaded.)
+android.sdk_path = /opt/android/android-sdk
+
+# (str) ANT directory (if empty, it will be automatically downloaded.)
+android.ant_path = /opt/android/apache-ant
+
+# (bool) If True, then skip trying to update the Android sdk
+# This can be useful to avoid excess Internet downloads or save time
+# when an update is due and you just want to test/build your package
+# note(ghost43): probably needed for reproducibility. versions pinned in Dockerfile.
+android.skip_update = True
+
+# (bool) If True, then automatically accept SDK license
+# agreements. This is intended for automation only. If set to False,
+# the default, you will be shown the license when first running
+# buildozer.
+android.accept_sdk_license = True
+
+# (str) Android entry point, default is ok for Kivy-based app
+#android.entrypoint = org.renpy.android.PythonActivity
+
+# (list) List of Java .jar files to add to the libs so that pyjnius can access
+# their classes. Don't add jars that you do not need, since extra jars can slow
+# down the build process. Allows wildcards matching, for example:
+# OUYA-ODK/libs/*.jar
+#android.add_jars = foo.jar,bar.jar,path/to/more/*.jar
+#android.add_jars = lib/android/zbar.jar
+
+android.add_jars = .buildozer/android/platform/*/build/libs_collections/Electrum/jar/*.jar
+
+
+android.add_aars =
+ contrib/android/.cache/aars/BarcodeScannerView.aar,
+ contrib/android/.cache/aars/CameraView.aar,
+ contrib/android/.cache/aars/zxing-cpp.aar
+
+
+# (list) List of Java files to add to the android project (can be java or a
+# directory containing the files)
+android.add_src = electrum/gui/qml/java_classes/
+
+# kotlin-stdlib is required for zxing-cpp (BarcodeScannerView)
+android.gradle_dependencies =
+ androidx.core:core:1.16.0,
+ org.jetbrains.kotlin:kotlin-stdlib:1.8.22
+
+android.add_activities = org.electrum.qr.SimpleScannerActivity, org.electrum.biometry.BiometricActivity
+
+# (list) Put these files or directories in the apk res directory.
+# The option may be used in three ways, the value may contain one or zero ':'
+# Some examples:
+# 1) A file to add to resources, legal resource names contain ['a-z','0-9','_']
+# android.add_resources = my_icons/all-inclusive.png:drawable/all_inclusive.png
+# 2) A directory, here 'legal_icons' must contain resources of one kind
+# android.add_resources = legal_icons:drawable
+# 3) A directory, here 'legal_resources' must contain one or more directories,
+# each of a resource kind: drawable, xml, etc...
+# android.add_resources = legal_resources
+android.add_resources = electrum/gui/qml/android_res/layout:layout
+
+# (str) python-for-android branch to use, if not master, useful to try
+# not yet merged features.
+#android.branch = master
+
+# (str) OUYA Console category. Should be one of GAME or APP
+# If you leave this blank, OUYA support will not be enabled
+#android.ouya.category = GAME
+
+# (str) Filename of OUYA Console icon. It must be a 732x412 png image.
+#android.ouya.icon.filename = %(source.dir)s/data/ouya_icon.png
+
+# (str) XML file to include as an intent filters in tag
+android.manifest.intent_filters = contrib/android/bitcoin_intent.xml
+
+# (str) launchMode to set for the main activity
+android.manifest.launch_mode = singleTask
+
+# (list) Android additional libraries to copy into libs/armeabi
+#android.add_libs_armeabi = lib/android/*.so
+
+# (bool) Indicate whether the screen should stay on
+# Don't forget to add the WAKE_LOCK permission if you set this to True
+#android.wakelock = False
+
+# (str) The Android arch to build for, choices: armeabi-v7a, arm64-v8a, x86, x86_64
+# note: can be overwritten by APP_ANDROID_ARCH env var
+#android.arch = armeabi-v7a
+
+# (int) overrides automatic versionCode computation (used in build.gradle)
+# this is not the same as app version and should only be edited if you know what you're doing
+# android.numeric_version = 1
+
+# (list) Android application meta-data to set (key=value format)
+#android.meta_data =
+
+# (list) Android library project to add (will be added in the
+# project.properties automatically.)
+#android.library_references =
+
+android.whitelist = lib-dynload/_csv.so
+
+# (bool) enables Android auto backup feature (Android API >=23)
+android.allow_backup = False
+
+# (str) The format used to package the app for release mode (aab or apk or aar).
+android.release_artifact = apk
+
+# (str) The format used to package the app for debug mode (apk or aar).
+android.debug_artifact = apk
+
+#
+# Python for android (p4a) specific
+#
+
+# (str) python-for-android git clone directory (if empty, it will be automatically cloned from github)
+p4a.source_dir = /opt/python-for-android
+
+# (str) The directory in which python-for-android should look for your own build recipes (if any)
+p4a.local_recipes = %(source.dir)s/contrib/android/p4a_recipes/
+
+# (str) Filename to the hook for p4a
+#p4a.hook =
+
+# (str) Bootstrap to use for android builds
+p4a.bootstrap = qt6
+
+# (int) port number to specify an explicit --port= p4a argument (eg for bootstrap flask)
+#p4a.port =
+
+
+#
+# iOS specific
+#
+
+# (str) Name of the certificate to use for signing the debug version
+# Get a list of available identities: buildozer ios list_identities
+#ios.codesign.debug = "iPhone Developer: ()"
+
+# (str) Name of the certificate to use for signing the release version
+#ios.codesign.release = %(ios.codesign.debug)s
+
+
+
+[buildozer]
+
+# (int) Log level (0 = error only, 1 = info, 2 = debug (with command output))
+log_level = 2
+
+# (str) Path to build output (i.e. .apk, .ipa) storage
+bin_dir = ./dist
+
+
+# -----------------------------------------------------------------------------
+# List as sections
+#
+# You can define all the "list" as [section:key].
+# Each line will be considered as a option to the list.
+# Let's take [app] / source.exclude_patterns.
+# Instead of doing:
+#
+# [app]
+# source.exclude_patterns = license,data/audio/*.wav,data/images/original/*
+#
+# This can be translated into:
+#
+# [app:source.exclude_patterns]
+# license
+# data/audio/*.wav
+# data/images/original/*
+#
+
+# -----------------------------------------------------------------------------
+# Profiles
+#
+# You can extend section / key with a profile
+# For example, you want to deploy a demo version of your application without
+# HD content. You could first change the title to add "(demo)" in the name
+# and extend the excluded directories to remove the HD content.
+#
+# [app@demo]
+# title = My Application (demo)
+#
+# [app:source.exclude_patterns@demo]
+# images/hd/*
+#
+# Then, invoke the command line with the "demo" profile:
+#
+# buildozer --profile demo android debug
diff --git a/contrib/android/get_apk_versioncode.py b/contrib/android/get_apk_versioncode.py
new file mode 100755
index 000000000000..2897ffe6ab8c
--- /dev/null
+++ b/contrib/android/get_apk_versioncode.py
@@ -0,0 +1,74 @@
+#!/usr/bin/python3
+
+import importlib.util
+import os
+import sys
+
+ARCH_DICT = {
+ "x86_64": "4",
+ "arm64-v8a": "3",
+ "armeabi-v7a": "2",
+ "x86": "1",
+ "null": "0",
+}
+
+
+def get_electrum_version() -> str:
+ project_root = os.path.abspath(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
+ version_file_path = os.path.join(project_root, "electrum", "version.py")
+ # load version.py; needlessly complicated alternative to "imp.load_source":
+ version_spec = importlib.util.spec_from_file_location('version', version_file_path)
+ version_module = version = importlib.util.module_from_spec(version_spec)
+ version_spec.loader.exec_module(version_module)
+ return version.ELECTRUM_VERSION
+
+
+def get_android_versioncode(*, arch_name: str) -> int:
+ version_code = 0
+ # add ELECTRUM_VERSION
+ app_version = get_electrum_version()
+ # if alpha/beta, and not stable: strip out alpha/beta part from last component.
+ # NOTE: we REUSE the version_code int between alphas/betas and the final stable.
+ # This is not allowed on Google Play or F-Droid.
+ # This means we MUST NOT upload alphas/betas there.
+ if any(c in app_version for c in ("a", "b")):
+ c_pos = app_version.find("a")
+ if c_pos == -1:
+ c_pos = app_version.find("b")
+ app_version = app_version[:c_pos]
+ # now the app_version str must contain exactly three dot-delimited components
+ app_version_components = app_version.split('.')
+ assert len(app_version_components) == 3, f"version str expected to have 3 components, but got {app_version!r}"
+ # convert to int
+ for i in app_version_components:
+ version_code *= 100
+ version_code += int(i)
+ # add arch
+ arch_code = ARCH_DICT[arch_name]
+ assert len(arch_code) == 1
+ version_code *= 10
+ version_code += int(arch_code)
+ # compensate for legacy scheme
+ # note: up until version 4.5.5, we used a different scheme for version_code.
+ # 4_______________4_05_05_00
+ # ^ android arch, ^ app_version (4.5.5.0)
+ # This offset ensures that all new-scheme version codes are larger than the old-scheme version codes.
+ offset_due_to_legacy_scheme = 45_000_000
+ version_code += offset_due_to_legacy_scheme
+ return version_code
+
+
+if __name__ == '__main__':
+ try:
+ android_arch = sys.argv[1]
+ except Exception:
+ print(f"usage: {os.path.basename(__file__)} ", file=sys.stderr)
+ sys.exit(1)
+ if android_arch not in ARCH_DICT:
+ print(f"usage: {os.path.basename(__file__)} ", file=sys.stderr)
+ print(f"error: unknown {android_arch=}", file=sys.stderr)
+ print(f" should be one of: {list(ARCH_DICT.keys())}", file=sys.stderr)
+ sys.exit(1)
+ version_code = get_android_versioncode(arch_name=android_arch)
+ assert isinstance(version_code, int), f"{version_code=!r} must be an int."
+ print(version_code, file=sys.stdout)
diff --git a/contrib/android/make_apk.sh b/contrib/android/make_apk.sh
new file mode 100755
index 000000000000..3f75c35fa404
--- /dev/null
+++ b/contrib/android/make_apk.sh
@@ -0,0 +1,122 @@
+#!/bin/bash
+
+set -e
+
+CONTRIB_ANDROID="$(dirname "$(readlink -e "$0")")"
+CONTRIB="$CONTRIB_ANDROID"/..
+PROJECT_ROOT="$CONTRIB"/..
+PACKAGES="$PROJECT_ROOT"/packages/
+
+. "$CONTRIB"/build_tools_util.sh
+
+git -C "$PROJECT_ROOT" rev-parse 2>/dev/null || fail "Building outside a git clone is not supported."
+
+
+# arguments have been checked in build.sh
+export ELEC_APK_GUI=$1
+
+if [ ! -d "$PACKAGES" ]; then
+ "$CONTRIB"/make_packages.sh || fail "make_packages failed"
+fi
+
+# update locale
+info "preparing electrum-locale."
+(
+ "$CONTRIB/locale/build_cleanlocale.sh"
+ # we want the binary to have only compiled (.mo) locale files; not source (.po) files
+ rm -r "$PROJECT_ROOT/electrum/locale/locale"/*/electrum.po
+)
+
+pushd "$CONTRIB_ANDROID"
+
+info "apk building phase starts."
+
+# Uncomment and change below to set a custom android package id,
+# e.g. to allow simultaneous mainnet and testnet installs of the apk.
+# defaults:
+#
+# export APP_PACKAGE_NAME=Electrum
+# export APP_PACKAGE_DOMAIN=org.electrum
+#
+# FIXME: changing "APP_PACKAGE_NAME" seems to require a clean rebuild of ".buildozer/".
+# However, even with a clean build, the build appears to break in the final stages (~4.7.0).
+# To avoid these issues; only change "APP_PACKAGE_DOMAIN" instead.
+#
+# So, in particular, to build testnet APKs, simply uncomment one of the following at a time (per-build):
+#
+# Testnet3
+#export APP_PACKAGE_DOMAIN=org.electrum.testnet
+#
+# Testnet4
+#export APP_PACKAGE_DOMAIN=org.electrum.testnet4
+
+if [ $CI ]; then
+ # override log level specified in buildozer.spec to "debug":
+ export BUILDOZER_LOG_LEVEL=2
+fi
+
+if [[ "$3" == "release" ]] ; then
+ # do release build, and sign the APKs.
+ TARGET="release"
+ export P4A_RELEASE_KEYSTORE_PASSWD="$4"
+ export P4A_RELEASE_KEYALIAS_PASSWD="$4"
+ export P4A_RELEASE_KEYSTORE=~/.keystore
+ export P4A_RELEASE_KEYALIAS=electrum
+ if [ -z "$P4A_RELEASE_KEYSTORE_PASSWD" ] || [ -z "$P4A_RELEASE_KEYALIAS_PASSWD" ]; then
+ echo "p4a password not defined"
+ exit 1
+ fi
+elif [[ "$3" == "release-unsigned" ]] ; then
+ # do release build, but do not sign the APKs.
+ TARGET="release"
+elif [[ "$3" == "debug" ]] ; then
+ # do debug build.
+ TARGET="apk"
+ export P4A_DEBUG_KEYSTORE="$CONTRIB_ANDROID"/android_debug.keystore
+ export P4A_DEBUG_KEYSTORE_PASSWD=unsafepassword
+ export P4A_DEBUG_KEYALIAS_PASSWD=unsafepassword
+ export P4A_DEBUG_KEYALIAS=electrum
+ # create keystore if needed
+ if [ ! -f "$P4A_DEBUG_KEYSTORE" ]; then
+ keytool -genkey -v -keystore "$CONTRIB_ANDROID"/android_debug.keystore \
+ -alias "$P4A_DEBUG_KEYALIAS" -keyalg RSA -keysize 2048 -validity 10000 \
+ -dname "CN=mqttserver.ibm.com, OU=ID, O=IBM, L=Hursley, S=Hants, C=GB" \
+ -storepass "$P4A_DEBUG_KEYSTORE_PASSWD" \
+ -keypass "$P4A_DEBUG_KEYALIAS_PASSWD"
+ fi
+ export ELEC_APK_USE_CURRENT_TIME=1
+else
+ fail "unknown build type"
+fi
+
+
+if [[ "$2" == "all" ]] ; then
+ # build all apks
+ # FIXME failures are not propagated out: we should fail the script if any arch build fails
+ export APP_ANDROID_ARCHS=armeabi-v7a
+ export APP_ANDROID_NUMERIC_VERSION=$("$CONTRIB_ANDROID"/get_apk_versioncode.py "$APP_ANDROID_ARCHS")
+ "$CONTRIB_ANDROID"/make_barcode_scanner.sh "$APP_ANDROID_ARCHS" || fail "make_barcode_scanner.sh failed"
+ make $TARGET
+
+ export APP_ANDROID_ARCHS=arm64-v8a
+ export APP_ANDROID_NUMERIC_VERSION=$("$CONTRIB_ANDROID"/get_apk_versioncode.py "$APP_ANDROID_ARCHS")
+ "$CONTRIB_ANDROID"/make_barcode_scanner.sh "$APP_ANDROID_ARCHS" || fail "make_barcode_scanner.sh failed"
+ make $TARGET
+
+ export APP_ANDROID_ARCHS=x86_64
+ export APP_ANDROID_NUMERIC_VERSION=$("$CONTRIB_ANDROID"/get_apk_versioncode.py "$APP_ANDROID_ARCHS")
+ "$CONTRIB_ANDROID"/make_barcode_scanner.sh "$APP_ANDROID_ARCHS" || fail "make_barcode_scanner.sh failed"
+ make $TARGET
+else
+ export APP_ANDROID_ARCHS=$2
+ export APP_ANDROID_NUMERIC_VERSION=$("$CONTRIB_ANDROID"/get_apk_versioncode.py "$APP_ANDROID_ARCHS")
+ "$CONTRIB_ANDROID"/make_barcode_scanner.sh "$APP_ANDROID_ARCHS" || fail "make_barcode_scanner.sh failed"
+ make $TARGET
+fi
+
+popd
+
+
+info "done."
+ls -la "$PROJECT_ROOT/dist"
+sha256sum "$PROJECT_ROOT/dist"/*
diff --git a/contrib/android/make_barcode_scanner.sh b/contrib/android/make_barcode_scanner.sh
new file mode 100755
index 000000000000..d0847b5b093a
--- /dev/null
+++ b/contrib/android/make_barcode_scanner.sh
@@ -0,0 +1,144 @@
+#!/bin/bash
+
+# script to clone and build https://github.com/markusfisch/BarcodeScannerView and its dependencies,
+# https://github.com/markusfisch/CameraView/ and https://github.com/markusfisch/zxing-cpp
+# which are being used as barcode scanner in the Android app.
+
+# To bump the version of BarcodeScannerView, get the newest version tag from the github repo,
+# then get the required dependencies from
+# https://github.com/markusfisch/BarcodeScannerView/blob/**VERSION_TAG**/barcodescannerview/build.gradle
+# then update the commit hashes below. Also update kotlin-stdlib in buildozer_qml.spec to the
+# "kotlin-version" specified in the used zxing-cpp commit:
+# https://github.com/markusfisch/zxing-cpp/blob/master/wrappers/aar/build.gradle
+
+
+BARCODE_SCANNER_VIEW_COMMIT_HASH="0bdb69269c252bb6daef2f871b76403c8b051945" # 1.6.5
+BARCODE_SCANNER_VIEW_REPO="https://github.com/markusfisch/BarcodeScannerView.git"
+
+CAMERA_VIEW_COMMIT_HASH="745597d05bc6abfdb3637a09a8ecaf30fdce7b6e" # 1.10.0
+CAMERA_VIEW_REPO="https://github.com/markusfisch/CameraView.git"
+
+ZXING_CPP_COMMIT_HASH="79f5adc6250e90de0bd635eb9181c5f8a18affda" # v2.3.0.4 using kotlin-stdlib 1.8.22
+ZXING_CPP_REPO="https://github.com/markusfisch/zxing-cpp.git"
+
+
+########################################################################################################
+set -e
+
+CONTRIB_ANDROID="$(dirname "$(readlink -e "$0")")"
+CONTRIB="$CONTRIB_ANDROID"/..
+CACHEDIR="$CONTRIB_ANDROID/.cache"
+BUILDDIR="$CACHEDIR/builds"
+
+. "$CONTRIB"/build_tools_util.sh
+
+# target architecture passed as argument by`make_apk.sh`
+TARGET_ARCH="$1"
+
+# check if TARGET_ARCH is set and supported
+if [[ "$TARGET_ARCH" != "armeabi-v7a" \
+ && "$TARGET_ARCH" != "arm64-v8a" \
+ && "$TARGET_ARCH" != "x86_64" ]]; then
+ fail "make_barcode_scanner.sh invalid target architecture argument: $TARGET_ARCH"
+fi
+
+info "Building BarcodeScannerView and deps for architecture: $TARGET_ARCH"
+
+# check if directories exist, create them if not
+if [ ! -d "$CACHEDIR/aars" ]; then
+ mkdir -p "$CACHEDIR/aars"
+fi
+
+if [ ! -d "$BUILDDIR" ]; then
+ mkdir -p "$BUILDDIR"
+fi
+
+
+####### zxing-cpp ########
+
+# check if zxing-cpp aar is already in cachedir, else build it
+ZXING_CPP_BUILD_ID="$TARGET_ARCH-$ZXING_CPP_COMMIT_HASH"
+if [ -f "$CACHEDIR/aars/zxing-cpp-$ZXING_CPP_BUILD_ID.aar" ]; then
+ info "zxing-cpp for $ZXING_CPP_BUILD_ID already exists in cache, skipping build."
+ cp "$CACHEDIR/aars/zxing-cpp-$ZXING_CPP_BUILD_ID.aar" "$CACHEDIR/aars/zxing-cpp.aar"
+else
+ info "Building zxing-cpp for $ZXING_CPP_BUILD_ID..."
+ ZXING_CPP_DIR="$BUILDDIR/zxing-cpp"
+ clone_or_update_repo "$ZXING_CPP_REPO" "$ZXING_CPP_COMMIT_HASH" "$ZXING_CPP_DIR"
+ cd "$ZXING_CPP_DIR/wrappers/aar"
+ chmod +x gradlew
+
+ # Set local.properties to use SDK of docker container
+ echo "sdk.dir=${ANDROID_SDK_HOME}" > local.properties
+ # gradlew will install a specific NDK version required by zxing-cpp
+ ./gradlew :zxingcpp:assembleRelease -Pandroid.injected.build.abi="$TARGET_ARCH"
+
+ # Copy the built AAR to cache directory
+ ZXING_AAR_SOURCE="$ZXING_CPP_DIR/wrappers/aar/zxingcpp/build/outputs/aar/zxingcpp-release.aar"
+ ZXING_AAR_DEST_GENERIC="$CACHEDIR/aars/zxing-cpp.aar"
+ ZXING_AAR_DEST_SPECIFIC="$CACHEDIR/aars/zxing-cpp-$ZXING_CPP_BUILD_ID.aar"
+ if [ ! -f "$ZXING_AAR_SOURCE" ]; then
+ fail "zxing-cpp AAR not found at $ZXING_AAR_SOURCE, build failed?"
+ fi
+ cp "$ZXING_AAR_SOURCE" "$ZXING_AAR_DEST_GENERIC"
+ # keeping an arch specific copy allows to skip the build later if it already exists
+ cp "$ZXING_AAR_SOURCE" "$ZXING_AAR_DEST_SPECIFIC"
+ info "zxing-cpp AAR copied to $ZXING_AAR_DEST_GENERIC"
+fi
+
+########### CameraView ###########
+
+CAMERA_VIEW_BUILD_ID="$CAMERA_VIEW_COMMIT_HASH"
+if [ -f "$CACHEDIR/aars/CameraView-$CAMERA_VIEW_BUILD_ID.aar" ]; then
+ info "CameraView AAR already exists in cache, skipping build."
+ cp "$CACHEDIR/aars/CameraView-$CAMERA_VIEW_BUILD_ID.aar" "$CACHEDIR/aars/CameraView.aar"
+else
+ info "Building CameraView..."
+ CAMERA_VIEW_DIR="$BUILDDIR/CameraView"
+ clone_or_update_repo "$CAMERA_VIEW_REPO" "$CAMERA_VIEW_COMMIT_HASH" "$CAMERA_VIEW_DIR"
+ cd "$CAMERA_VIEW_DIR"
+ chmod +x gradlew
+
+ echo "sdk.dir=${ANDROID_SDK_HOME}" > local.properties
+ ./gradlew :cameraview:assembleRelease
+
+ CAMERA_AAR_SOURCE="$CAMERA_VIEW_DIR/cameraview/build/outputs/aar/cameraview-release.aar"
+ CAMERA_AAR_DEST_GENERIC="$CACHEDIR/aars/CameraView.aar"
+ CAMERA_AAR_DEST_SPECIFIC="$CACHEDIR/aars/CameraView-$CAMERA_VIEW_BUILD_ID.aar"
+ if [ ! -f "$CAMERA_AAR_SOURCE" ]; then
+ fail "CameraView AAR not found at $CAMERA_AAR_SOURCE"
+ fi
+ cp "$CAMERA_AAR_SOURCE" "$CAMERA_AAR_DEST_GENERIC"
+ cp "$CAMERA_AAR_SOURCE" "$CAMERA_AAR_DEST_SPECIFIC"
+ info "CameraView AAR copied to $CAMERA_AAR_DEST_GENERIC"
+fi
+
+########### BarcodeScannerView ###########
+
+BARCODE_SCANNER_VIEW_BUILD_ID="$BARCODE_SCANNER_VIEW_COMMIT_HASH"
+if [ -f "$CACHEDIR/aars/BarcodeScannerView-$BARCODE_SCANNER_VIEW_BUILD_ID.aar" ]; then
+ info "BarcodeScannerView AAR already exists in cache, skipping build."
+ cp "$CACHEDIR/aars/BarcodeScannerView-$BARCODE_SCANNER_VIEW_BUILD_ID.aar" "$CACHEDIR/aars/BarcodeScannerView.aar"
+else
+ info "Building BarcodeScannerView..."
+ BARCODE_SCANNER_VIEW_DIR="$BUILDDIR/BarcodeScannerView"
+ clone_or_update_repo "$BARCODE_SCANNER_VIEW_REPO" "$BARCODE_SCANNER_VIEW_COMMIT_HASH" "$BARCODE_SCANNER_VIEW_DIR"
+ cd "$BARCODE_SCANNER_VIEW_DIR"
+ chmod +x gradlew
+
+ echo "sdk.dir=${ANDROID_SDK_HOME}" > local.properties
+ ./gradlew :barcodescannerview:assembleRelease
+
+ BARCODE_AAR_SOURCE="$BARCODE_SCANNER_VIEW_DIR/barcodescannerview/build/outputs/aar/barcodescannerview-release.aar"
+ BARCODE_AAR_DEST_GENERIC="$CACHEDIR/aars/BarcodeScannerView.aar"
+ BARCODE_AAR_DEST_SPECIFIC="$CACHEDIR/aars/BarcodeScannerView-$BARCODE_SCANNER_VIEW_BUILD_ID.aar"
+ if [ ! -f "$BARCODE_AAR_SOURCE" ]; then
+ fail "BarcodeScannerView AAR not found at $BARCODE_AAR_SOURCE"
+ fi
+ cp "$BARCODE_AAR_SOURCE" "$BARCODE_AAR_DEST_GENERIC"
+ cp "$BARCODE_AAR_SOURCE" "$BARCODE_AAR_DEST_SPECIFIC"
+ info "BarcodeScannerView AAR copied to $BARCODE_AAR_DEST_GENERIC"
+fi
+
+
+info "All barcode scanner libraries built successfully for $TARGET_ARCH"
diff --git a/contrib/android/p4a_recipes/README.md b/contrib/android/p4a_recipes/README.md
new file mode 100644
index 000000000000..3c4067909788
--- /dev/null
+++ b/contrib/android/p4a_recipes/README.md
@@ -0,0 +1,10 @@
+python-for-android local recipes
+--------------------------------
+
+These folders are recipes (build scripts) for most of our direct and transitive
+dependencies for the Android app. python-for-android has recipes built-in for
+many packages but it also allows users to specify their "local" recipes.
+Local recipes have precedence over the built-in recipes.
+
+The local recipes we have here are mostly just used to pin down specific
+versions and hashes for reproducibility. The hashes are updated manually.
diff --git a/contrib/android/p4a_recipes/android/__init__.py b/contrib/android/p4a_recipes/android/__init__.py
new file mode 100644
index 000000000000..17bd29ad2cbe
--- /dev/null
+++ b/contrib/android/p4a_recipes/android/__init__.py
@@ -0,0 +1,20 @@
+import os
+
+from pythonforandroid.recipes.android import AndroidRecipe
+from pythonforandroid.util import load_source, HashPinnedDependency
+
+util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
+
+
+assert AndroidRecipe.depends == [('sdl3', 'sdl2', 'genericndkbuild', 'qt6'), 'pyjnius', 'python3'], AndroidRecipe.depends
+assert AndroidRecipe.python_depends == []
+
+
+class AndroidRecipePinned(util.InheritedRecipeMixin, AndroidRecipe):
+ hostpython_prerequisites = [
+ HashPinnedDependency(package="Cython==3.1.8",
+ hashes=['sha256:282b3c8e6abc3fea421919e862e898ffdd86fc0796009bdb5ffdf8211413219f'])
+ ]
+
+
+recipe = AndroidRecipePinned()
diff --git a/contrib/android/p4a_recipes/cffi/__init__.py b/contrib/android/p4a_recipes/cffi/__init__.py
new file mode 100644
index 000000000000..a5bbf2b32c3b
--- /dev/null
+++ b/contrib/android/p4a_recipes/cffi/__init__.py
@@ -0,0 +1,18 @@
+import os
+
+from pythonforandroid.recipes.cffi import CffiRecipe
+from pythonforandroid.util import load_source
+
+util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
+
+
+assert CffiRecipe._version == "2.0.0"
+assert CffiRecipe.depends == ['pycparser', 'libffi', 'python3'], CffiRecipe.depends
+assert CffiRecipe.python_depends == []
+
+
+class CffiRecipePinned(util.InheritedRecipeMixin, CffiRecipe):
+ sha512sum = "a71b74e642e11eb50e9bb4ae0e7116bdb3c4a7c9622a3766d84506fa7994c02e09644b41b439b95ca99b0303e91891897cff38018d498eb087e0961f0ad4fb8b"
+
+
+recipe = CffiRecipePinned()
diff --git a/contrib/android/p4a_recipes/cryptography/__init__.py b/contrib/android/p4a_recipes/cryptography/__init__.py
new file mode 100644
index 000000000000..5c6bd1bf2484
--- /dev/null
+++ b/contrib/android/p4a_recipes/cryptography/__init__.py
@@ -0,0 +1,13 @@
+from pythonforandroid.recipes.cryptography import CryptographyRecipe
+
+
+assert CryptographyRecipe._version == "2.8"
+assert CryptographyRecipe.depends == ['openssl', 'six', 'setuptools', 'cffi', 'python3']
+assert CryptographyRecipe.python_depends == []
+
+
+class CryptographyRecipePinned(CryptographyRecipe):
+ sha512sum = "000816a5513691bfbb01c5c65d96fb3567a5ff25300da4b485e716b6d4dc789aec05ed0fe65df9c5e3e60127aa9110f04e646407db5b512f88882b0659f7123f"
+
+
+recipe = CryptographyRecipePinned()
diff --git a/contrib/android/p4a_recipes/hostpython3/__init__.py b/contrib/android/p4a_recipes/hostpython3/__init__.py
new file mode 100644
index 000000000000..a4a63d7bd3f5
--- /dev/null
+++ b/contrib/android/p4a_recipes/hostpython3/__init__.py
@@ -0,0 +1,35 @@
+import os
+
+from pythonforandroid.recipes.hostpython3 import HostPython3Recipe
+from pythonforandroid.util import load_source, HashPinnedDependency
+
+util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
+
+
+assert HostPython3Recipe.depends == []
+assert HostPython3Recipe.python_depends == []
+
+
+class HostPython3RecipePinned(util.InheritedRecipeMixin, HostPython3Recipe):
+ # PYTHON_VERSION= # < line here so that I can grep the codebase and teleport here
+ version = "3.11.14"
+ sha512sum = "4642f6d59c76c6e5dbd827fdb28694376a9cc76e513146d092b49afb41513b3c9dff2339cfcebfb5b260f5cdc49a59a69906e284e5d478b2189d3374e9e24fd5"
+
+ # this property overrides the default hostpython dependencies for PyProjectRecipe recipies
+ pyproject_base_dependencies = [
+ HashPinnedDependency(package="build==1.4.0",
+ hashes=['sha256:6a07c1b8eb6f2b311b96fcbdbce5dab5fe637ffda0fd83c9cac622e927501596']),
+ HashPinnedDependency(package="pip==24.0",
+ hashes=['sha256:ba0d021a166865d2265246961bec0152ff124de910c5cc39f1156ce3fa7c69dc']),
+ HashPinnedDependency(package="setuptools==80.9.0",
+ hashes=['sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922']),
+
+ # pin deptree build==1.4.0
+ HashPinnedDependency(package="packaging==26.0",
+ hashes=['sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529']),
+ HashPinnedDependency(package="pyproject_hooks==1.2.0",
+ hashes=['sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913']),
+ ]
+
+
+recipe = HostPython3RecipePinned()
diff --git a/contrib/android/p4a_recipes/libffi/__init__.py b/contrib/android/p4a_recipes/libffi/__init__.py
new file mode 100644
index 000000000000..ccb52f8976b4
--- /dev/null
+++ b/contrib/android/p4a_recipes/libffi/__init__.py
@@ -0,0 +1,19 @@
+import os
+
+from pythonforandroid.recipes.libffi import LibffiRecipe
+from pythonforandroid.util import load_source
+
+util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
+
+
+assert LibffiRecipe._version == "v3.4.2"
+assert LibffiRecipe.depends == []
+assert LibffiRecipe.python_depends == []
+
+
+class LibffiRecipePinned(util.InheritedRecipeMixin, LibffiRecipe):
+ version = "v3.4.8"
+ sha512sum = "064a43ddae005f3d0fa56db4da6071fae93aaae87a755b84888c0cb9c8fa2fe9bb452b3d9a382fab64c442c19d98a20ba15b8be92eba7bf3773815b31fb7824c"
+
+
+recipe = LibffiRecipePinned()
diff --git a/contrib/android/p4a_recipes/libiconv/__init__.py b/contrib/android/p4a_recipes/libiconv/__init__.py
new file mode 100644
index 000000000000..58dc8f7afbe4
--- /dev/null
+++ b/contrib/android/p4a_recipes/libiconv/__init__.py
@@ -0,0 +1,19 @@
+import os
+
+from pythonforandroid.recipes.libiconv import LibIconvRecipe
+from pythonforandroid.util import load_source
+
+util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
+
+
+assert LibIconvRecipe._version == "1.16"
+assert LibIconvRecipe.depends == []
+assert LibIconvRecipe.python_depends == []
+
+
+class LibIconvRecipePinned(util.InheritedRecipeMixin, LibIconvRecipe):
+ version = "1.18"
+ sha512sum = "a55eb3b7b785a78ab8918db8af541c9e11deb5ff4f89d54483287711ed797d87848ce0eafffa7ce26d9a7adb4b5a9891cb484f94bd4f51d3ce97a6a47b4c719a"
+
+
+recipe = LibIconvRecipePinned()
diff --git a/contrib/android/p4a_recipes/libsecp256k1/__init__.py b/contrib/android/p4a_recipes/libsecp256k1/__init__.py
new file mode 100644
index 000000000000..35d171c3b8cb
--- /dev/null
+++ b/contrib/android/p4a_recipes/libsecp256k1/__init__.py
@@ -0,0 +1,14 @@
+from pythonforandroid.recipes.libsecp256k1 import LibSecp256k1Recipe
+
+
+assert LibSecp256k1Recipe.depends == []
+assert LibSecp256k1Recipe.python_depends == []
+
+
+class LibSecp256k1RecipePinned(LibSecp256k1Recipe):
+ version = "1a53f4961f337b4d166c25fce72ef0dc88806618"
+ url = "https://github.com/bitcoin-core/secp256k1/archive/{version}.zip"
+ sha512sum = "4072e45517bc1bb416250bc8e4fa4ed94f83b4eebbe25a70925fd7cc9759df3edbce64ab0116519c335f82353f6a029cde92018ed7116f2f85c8092a9adeb532"
+
+
+recipe = LibSecp256k1RecipePinned()
diff --git a/contrib/android/p4a_recipes/libzbar/__init__.py b/contrib/android/p4a_recipes/libzbar/__init__.py
new file mode 100644
index 000000000000..2a8fb9b23873
--- /dev/null
+++ b/contrib/android/p4a_recipes/libzbar/__init__.py
@@ -0,0 +1,20 @@
+import os
+
+from pythonforandroid.recipes.libzbar import LibZBarRecipe
+from pythonforandroid.util import load_source
+
+util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
+
+
+assert LibZBarRecipe.depends == ['libiconv']
+assert LibZBarRecipe.python_depends == []
+
+
+class LibZBarRecipePinned(util.InheritedRecipeMixin, LibZBarRecipe):
+ version = "bb05ec54eec57f8397cb13fb9161372a281a1219"
+ url = "https://github.com/mchehab/zbar/archive/{version}.zip"
+ sha512sum = "186312ef0a50404efef79a5fbed34534569fab2873a6bb6d2e3d8ea64fa461c5537ca4fb0e659670d72b021e514f8fd4651b1e85954bf987015d8eb2e6f68375"
+ patches = [] # werror.patch not needed for modern zbar
+
+
+recipe = LibZBarRecipePinned()
diff --git a/contrib/android/p4a_recipes/openssl/__init__.py b/contrib/android/p4a_recipes/openssl/__init__.py
new file mode 100644
index 000000000000..062b18e58a5b
--- /dev/null
+++ b/contrib/android/p4a_recipes/openssl/__init__.py
@@ -0,0 +1,19 @@
+import os
+
+from pythonforandroid.recipes.openssl import OpenSSLRecipe
+from pythonforandroid.util import load_source
+
+util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
+
+
+# assert OpenSSLRecipe._version == "3.3.1"
+assert OpenSSLRecipe.depends == []
+assert OpenSSLRecipe.python_depends == []
+
+
+class OpenSSLRecipePinned(util.InheritedRecipeMixin, OpenSSLRecipe):
+ version = "3.0.18"
+ sha512sum = "6bdd16f33b83ae2a12777230c4ff00d0595bbc00253ac8c3ac31e1375e818fc74d7f491bd2e507ff33cab9f0498cfb28fa8690f75a98663568d40901523cdf3c"
+
+
+recipe = OpenSSLRecipePinned()
diff --git a/contrib/android/p4a_recipes/packaging/__init__.py b/contrib/android/p4a_recipes/packaging/__init__.py
new file mode 100644
index 000000000000..b16d67d47941
--- /dev/null
+++ b/contrib/android/p4a_recipes/packaging/__init__.py
@@ -0,0 +1,13 @@
+from pythonforandroid.recipes.packaging import PackagingRecipe
+
+
+assert PackagingRecipe._version == "26.0"
+assert PackagingRecipe.depends == ["setuptools", "pyparsing", "python3"]
+assert PackagingRecipe.python_depends == []
+
+
+class PackagingRecipePinned(PackagingRecipe):
+ sha512sum = "27a066a7d65ba76189212973b6a0d162f3d361848b1b0c34a82865cf180b3284a837cc34206c297f002a73feae414e25a26c5960bb884a74ea337f582585f1d2"
+
+
+recipe = PackagingRecipePinned()
diff --git a/contrib/android/p4a_recipes/ply/__init__.py b/contrib/android/p4a_recipes/ply/__init__.py
new file mode 100644
index 000000000000..210b70ad1092
--- /dev/null
+++ b/contrib/android/p4a_recipes/ply/__init__.py
@@ -0,0 +1,18 @@
+import os
+
+from pythonforandroid.recipes.ply import PlyRecipe
+from pythonforandroid.util import load_source
+
+util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
+
+
+assert PlyRecipe._version == "3.11"
+assert PlyRecipe.depends == ['packaging', 'python3']
+assert PlyRecipe.python_depends == []
+
+
+class PlyRecipePinned(util.InheritedRecipeMixin, PlyRecipe):
+ sha512sum = "37e39a4f930874933223be58a3da7f259e155b75135f1edd47069b3b40e5e96af883ebf1c8a1bbd32f914a9e92cfc12e29fec05cf61b518f46c1d37421b20008"
+
+
+recipe = PlyRecipePinned()
diff --git a/contrib/android/p4a_recipes/plyer/__init__.py b/contrib/android/p4a_recipes/plyer/__init__.py
new file mode 100644
index 000000000000..491e00079296
--- /dev/null
+++ b/contrib/android/p4a_recipes/plyer/__init__.py
@@ -0,0 +1,14 @@
+from pythonforandroid.recipe import PythonRecipe
+
+
+assert PythonRecipe.depends == ['python3']
+assert PythonRecipe.python_depends == []
+
+
+class PlyerRecipePinned(PythonRecipe):
+ version = "5262087c85b2c82c69e702fe944069f1d8465fdf"
+ url = "git+https://github.com/SomberNight/plyer"
+ depends = ["setuptools"]
+
+
+recipe = PlyerRecipePinned()
diff --git a/contrib/android/p4a_recipes/pycparser/__init__.py b/contrib/android/p4a_recipes/pycparser/__init__.py
new file mode 100644
index 000000000000..aa1cf46d452c
--- /dev/null
+++ b/contrib/android/p4a_recipes/pycparser/__init__.py
@@ -0,0 +1,14 @@
+from pythonforandroid.recipes.pycparser import PycparserRecipe
+
+
+assert PycparserRecipe._version == "2.14"
+assert PycparserRecipe.depends == ['setuptools', 'python3']
+assert PycparserRecipe.python_depends == []
+
+
+class PycparserRecipePinned(PycparserRecipe):
+ version = "2.22"
+ sha512sum = "c9a81c78d87162f71281a32a076b279f4f7f2e17253fe14c89c6db5f9b3554a6563ff700c385549a8b51ef8832f99f7bb4ac07f22754c7c475dd91feeb0cf87f"
+
+
+recipe = PycparserRecipePinned()
diff --git a/contrib/android/p4a_recipes/pycryptodomex/__init__.py b/contrib/android/p4a_recipes/pycryptodomex/__init__.py
new file mode 100644
index 000000000000..0491968e594b
--- /dev/null
+++ b/contrib/android/p4a_recipes/pycryptodomex/__init__.py
@@ -0,0 +1,19 @@
+from pythonforandroid.recipe import PythonRecipe
+from pythonforandroid.util import HashPinnedDependency
+
+assert PythonRecipe.depends == ['python3']
+assert PythonRecipe.python_depends == []
+
+
+class PycryptodomexRecipe(PythonRecipe):
+ version = "3.23.0"
+ sha512sum = "951cebaad2e19b9f9d04fe85c73ab1ff8b515069c1e0e8e3cd6845ec9ccd5ef3e5737259e0934ed4a6536e289dee6aabac58e1c822a5a6393e86b482c60afc89"
+ url = "https://github.com/Legrandin/pycryptodome/archive/v{version}x.tar.gz"
+ depends = ["cffi"]
+ hostpython_prerequisites = [
+ HashPinnedDependency(package="setuptools==80.9.0",
+ hashes=['sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922']),
+ ]
+
+
+recipe = PycryptodomexRecipe()
diff --git a/contrib/android/p4a_recipes/pyjnius/__init__.py b/contrib/android/p4a_recipes/pyjnius/__init__.py
new file mode 100644
index 000000000000..267f5d9a389d
--- /dev/null
+++ b/contrib/android/p4a_recipes/pyjnius/__init__.py
@@ -0,0 +1,23 @@
+import os
+
+from pythonforandroid.recipes.pyjnius import PyjniusRecipe
+from pythonforandroid.util import load_source, HashPinnedDependency
+
+util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
+
+
+assert PyjniusRecipe._version == "1.7.0"
+assert PyjniusRecipe.depends == [('genericndkbuild', 'sdl2', 'sdl3', 'qt6'), 'six', 'python3'], PyjniusRecipe.depends
+assert PyjniusRecipe.python_depends == []
+
+
+class PyjniusRecipePinned(util.InheritedRecipeMixin, PyjniusRecipe):
+ hostpython_prerequisites = [
+ HashPinnedDependency(package="Cython==3.1.8",
+ hashes=['sha256:282b3c8e6abc3fea421919e862e898ffdd86fc0796009bdb5ffdf8211413219f'])
+ ]
+
+ sha512sum = "a192c30ef87ca9601455976feb49f03dfdb8e1bf2545744a7b771a6d0930a56b334c7a2a39d30fb8855c070f16e4673dc5ff6920b04a6155ab5f9247b271df76"
+
+
+recipe = PyjniusRecipePinned()
diff --git a/contrib/android/p4a_recipes/pyparsing/__init__.py b/contrib/android/p4a_recipes/pyparsing/__init__.py
new file mode 100644
index 000000000000..c6a3e9e1e898
--- /dev/null
+++ b/contrib/android/p4a_recipes/pyparsing/__init__.py
@@ -0,0 +1,27 @@
+from pythonforandroid.recipes.pyparsing import PyparsingRecipe
+from pythonforandroid.util import HashPinnedDependency
+
+
+assert PyparsingRecipe._version == "3.0.7"
+assert PyparsingRecipe.depends == ["setuptools", "python3"]
+assert PyparsingRecipe.python_depends == []
+
+
+class PyparsingRecipePinned(PyparsingRecipe):
+ #version = "3.0.7"
+ # note: 3.0.7 is the last version to use setup.py, so newer versions don't work,
+ # as p4a runs "$ python3 setup.py install". This is only going become a larger problem, needs fix upstream.
+ # see https://github.com/kivy/python-for-android/blob/be3de2e28e5a52d5f8949f3969f8a3b7f9eb3cba/pythonforandroid/recipe.py#L983
+ # - but maybe upstream p4a already has a workaround?
+ # see "PyProjectRecipe" from https://github.com/kivy/python-for-android/pull/3007
+ sha512sum = "1e692f4cdaa6b6e8ca2729d0a3e2ba16d978f1957c538b6de3a4220ec7d996bdbe87c41c43abab851fffa3b0498a05841373e435602917b8c095042e273badb5"
+
+ hostpython_prerequisites = [
+ HashPinnedDependency(package="setuptools==80.9.0",
+ hashes=['sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922']),
+ HashPinnedDependency(package="pip==24.0",
+ hashes=['sha256:ba0d021a166865d2265246961bec0152ff124de910c5cc39f1156ce3fa7c69dc']),
+ ]
+
+
+recipe = PyparsingRecipePinned()
diff --git a/contrib/android/p4a_recipes/pyqt6/__init__.py b/contrib/android/p4a_recipes/pyqt6/__init__.py
new file mode 100644
index 000000000000..1f08245d207c
--- /dev/null
+++ b/contrib/android/p4a_recipes/pyqt6/__init__.py
@@ -0,0 +1,18 @@
+import os
+
+from pythonforandroid.recipes.pyqt6 import PyQt6Recipe
+from pythonforandroid.util import load_source, HashPinnedDependency
+
+util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
+
+
+assert PyQt6Recipe._version == "6.10.2"
+assert PyQt6Recipe.depends == ['qt6', 'pyjnius', 'setuptools', 'pyqt6sip', 'hostpython3', 'pyqt_builder', 'python3'], PyQt6Recipe.depends
+assert PyQt6Recipe.python_depends == []
+
+
+class PyQt6RecipePinned(util.InheritedRecipeMixin, PyQt6Recipe):
+ sha512sum = "d58515d181530fdd71edc3edfa0b647a3aeeb56cbc33f4d7fd0d40a7a99d52298ac5bb4438b5dadea5439759e52cc459e601f1fab5d9afdd61f2a492d0bae1ef"
+
+
+recipe = PyQt6RecipePinned()
diff --git a/contrib/android/p4a_recipes/pyqt6sip/__init__.py b/contrib/android/p4a_recipes/pyqt6sip/__init__.py
new file mode 100644
index 000000000000..dea22376f50b
--- /dev/null
+++ b/contrib/android/p4a_recipes/pyqt6sip/__init__.py
@@ -0,0 +1,25 @@
+import os
+
+from pythonforandroid.recipes.pyqt6sip import PyQt6SipRecipe
+from pythonforandroid.util import load_source, HashPinnedDependency
+
+util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
+
+
+assert PyQt6SipRecipe._version == "13.10.3"
+assert PyQt6SipRecipe.depends == ['python3']
+assert PyQt6SipRecipe.python_depends == []
+
+
+class PyQt6SipRecipePinned(util.InheritedRecipeMixin, PyQt6SipRecipe):
+ sha512sum = "555b061eec3db6a66388fae07de21f58d756f6f12b13e4ede729c3348d2c8997ac5a59d3006ee45c3a09b5cde673f579265fa254bc583a4ba721748cf8f3a617"
+
+ hostpython_prerequisites = [
+ HashPinnedDependency(package="setuptools==80.9.0",
+ hashes=['sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922']),
+ HashPinnedDependency(package="packaging==26.0",
+ hashes=['sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529']),
+ ]
+
+
+recipe = PyQt6SipRecipePinned()
diff --git a/contrib/android/p4a_recipes/pyqt_builder/__init__.py b/contrib/android/p4a_recipes/pyqt_builder/__init__.py
new file mode 100644
index 000000000000..8fa65576d87c
--- /dev/null
+++ b/contrib/android/p4a_recipes/pyqt_builder/__init__.py
@@ -0,0 +1,14 @@
+from pythonforandroid.recipes.pyqt_builder import PyQtBuilderRecipe
+from pythonforandroid.util import HashPinnedDependency
+
+
+assert PyQtBuilderRecipe._version == "1.19.1"
+assert PyQtBuilderRecipe.depends == ["sip", "python3"], PyQtBuilderRecipe.depends
+assert PyQtBuilderRecipe.python_depends == []
+
+
+class PyQtBuilderRecipePinned(PyQtBuilderRecipe):
+ sha512sum = "2308c51f93c37b1d13f312e4f2475d26b22d374ef284925fead9eab4aa89b994770431aca45170ac2154b4813fff151798f113f56d4cbf6c6e544fb463104a6d"
+
+
+recipe = PyQtBuilderRecipePinned()
diff --git a/contrib/android/p4a_recipes/python3/__init__.py b/contrib/android/p4a_recipes/python3/__init__.py
new file mode 100644
index 000000000000..11cf395461b9
--- /dev/null
+++ b/contrib/android/p4a_recipes/python3/__init__.py
@@ -0,0 +1,19 @@
+import os
+
+from pythonforandroid.recipes.python3 import Python3Recipe
+from pythonforandroid.util import load_source
+
+util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
+
+
+assert Python3Recipe.depends == ['hostpython3', 'sqlite3', 'openssl', 'libffi']
+assert Python3Recipe.python_depends == []
+
+
+class Python3RecipePinned(util.InheritedRecipeMixin, Python3Recipe):
+ # PYTHON_VERSION= # < line here so that I can grep the codebase and teleport here
+ version = "3.11.14"
+ sha512sum = "4642f6d59c76c6e5dbd827fdb28694376a9cc76e513146d092b49afb41513b3c9dff2339cfcebfb5b260f5cdc49a59a69906e284e5d478b2189d3374e9e24fd5"
+
+
+recipe = Python3RecipePinned()
diff --git a/contrib/android/p4a_recipes/qt6/__init__.py b/contrib/android/p4a_recipes/qt6/__init__.py
new file mode 100644
index 000000000000..dcb443d9a398
--- /dev/null
+++ b/contrib/android/p4a_recipes/qt6/__init__.py
@@ -0,0 +1,17 @@
+import os
+
+from pythonforandroid.recipes.qt6 import Qt6Recipe
+from pythonforandroid.util import load_source
+
+util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
+
+assert Qt6Recipe._version == "6.10.2"
+assert Qt6Recipe.depends == ['python3', 'hostqt6']
+assert Qt6Recipe.python_depends == []
+
+
+class Qt6RecipePinned(util.InheritedRecipeMixin, Qt6Recipe):
+ sha512sum = "bf1a1d42d57b4d2e77f7227f4cbe01e847fd65035461b89481063b32f25a57be6e5a07889acc4af65ca9ff9d27b7fe63bd2fe60b8aa7fa19d554394d799fbaa1"
+
+
+recipe = Qt6RecipePinned()
diff --git a/contrib/android/p4a_recipes/sip/__init__.py b/contrib/android/p4a_recipes/sip/__init__.py
new file mode 100644
index 000000000000..af6fdffb44f1
--- /dev/null
+++ b/contrib/android/p4a_recipes/sip/__init__.py
@@ -0,0 +1,18 @@
+from pythonforandroid.recipes.sip import SipRecipe
+from pythonforandroid.util import HashPinnedDependency
+
+assert SipRecipe._version == "6.15.1"
+assert SipRecipe.depends == ["python3"], SipRecipe.depends
+assert SipRecipe.python_depends == []
+
+
+class SipRecipePinned(SipRecipe):
+ sha512sum = "30a312419ba82c0221c0cf03c3fb3ad7d45bb8fe633d1d7477025a7986b0a7f7b7b781a8d9cd6bcdb78f3b872231fd1eed123a761b497861822f2e35093f574d"
+
+ hostpython_prerequisites = [
+ HashPinnedDependency(package="setuptools==80.9.0",
+ hashes=['sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922']),
+ ]
+
+
+recipe = SipRecipePinned()
diff --git a/contrib/android/p4a_recipes/six/__init__.py b/contrib/android/p4a_recipes/six/__init__.py
new file mode 100644
index 000000000000..2ad31eb0290d
--- /dev/null
+++ b/contrib/android/p4a_recipes/six/__init__.py
@@ -0,0 +1,14 @@
+from pythonforandroid.recipes.six import SixRecipe
+
+
+assert SixRecipe._version == "1.15.0"
+assert SixRecipe.depends == ['setuptools', 'python3']
+assert SixRecipe.python_depends == []
+
+
+class SixRecipePinned(SixRecipe):
+ version = "1.17.0"
+ sha512sum = "fcfa58b03877ac3ac00a4f85b5fea4fecb2a010244451aa95013637a0aa21529f3dcfe25c0a07c72da46da1fa12bc0c16b6c641c40c6ab2133e5b5cbb5a71e4b"
+
+
+recipe = SixRecipePinned()
diff --git a/contrib/android/p4a_recipes/sqlite3/__init__.py b/contrib/android/p4a_recipes/sqlite3/__init__.py
new file mode 100644
index 000000000000..ffc9aa7fb234
--- /dev/null
+++ b/contrib/android/p4a_recipes/sqlite3/__init__.py
@@ -0,0 +1,20 @@
+import os
+
+from pythonforandroid.recipes.sqlite3 import Sqlite3Recipe
+from pythonforandroid.util import load_source
+
+util = load_source('util', os.path.join(os.path.dirname(os.path.dirname(__file__)), 'util.py'))
+
+
+assert Sqlite3Recipe._version == "3.35.5"
+assert Sqlite3Recipe.depends == []
+assert Sqlite3Recipe.python_depends == []
+
+
+class Sqlite3RecipePinned(util.InheritedRecipeMixin, Sqlite3Recipe):
+ version = "3.50.0"
+ url = 'https://www.sqlite.org/2025/sqlite-amalgamation-3500000.zip'
+ sha512sum = "0fd87f2b8140300ce165600f6708aafef19041a181e9f00ed14f7aeaa3c06805c8c54c53751a9ce74d4d666f018ca6f48e3f5b5c874ccb9e1424a528c92326f0"
+
+
+recipe = Sqlite3RecipePinned()
diff --git a/contrib/android/p4a_recipes/toml/__init__.py b/contrib/android/p4a_recipes/toml/__init__.py
new file mode 100644
index 000000000000..21f9035643f9
--- /dev/null
+++ b/contrib/android/p4a_recipes/toml/__init__.py
@@ -0,0 +1,13 @@
+from pythonforandroid.recipes.toml import TomlRecipe
+
+
+assert TomlRecipe._version == "0.10.2"
+assert TomlRecipe.depends == ["setuptools", "python3"]
+assert TomlRecipe.python_depends == []
+
+
+class TomlRecipePinned(TomlRecipe):
+ sha512sum = "ede2c8fed610a3827dba828f6e7ab7a8dbd5745e8ef7c0cd955219afdc83b9caea714deee09e853627f05ad1c525dc60426a6e9e16f58758aa028cb4d3db4b39"
+
+
+recipe = TomlRecipePinned()
diff --git a/contrib/android/p4a_recipes/tomli/__init__.py b/contrib/android/p4a_recipes/tomli/__init__.py
new file mode 100644
index 000000000000..6f3c623962cc
--- /dev/null
+++ b/contrib/android/p4a_recipes/tomli/__init__.py
@@ -0,0 +1,15 @@
+from pythonforandroid.recipes.tomli import TomliRecipe
+
+
+assert TomliRecipe._version == "2.0.1"
+assert TomliRecipe.depends == ["setuptools", "python3"]
+assert TomliRecipe.python_depends == []
+
+
+class TomliRecipePinned(TomliRecipe):
+ #version = "2.0.1"
+ # note: can't be easily updated as base recipe has version number hardcoded in custom "patch"-like setup.py
+ sha512sum = "fd410039e255e2b3359e999d69a5a2d38b9b89b77e8557f734f2621dfbd5e1207e13aecc11589197ec22594c022f07f41b4cfe486a3a719281a595c95fd19ecf"
+
+
+recipe = TomliRecipePinned()
diff --git a/contrib/android/p4a_recipes/util.py b/contrib/android/p4a_recipes/util.py
new file mode 100644
index 000000000000..ffdd3ff73bf4
--- /dev/null
+++ b/contrib/android/p4a_recipes/util.py
@@ -0,0 +1,12 @@
+import os
+
+
+class InheritedRecipeMixin:
+
+ def get_recipe_dir(self):
+ """This is used to replace pythonforandroid.recipe.Recipe.get_recipe_dir.
+ If one of our local recipes inherits from a built-in p4a recipe, this override
+ ensures that potential patches and other local files used by the recipe will
+ be looked for in the built-in recipe's folder.
+ """
+ return os.path.join(self.ctx.root_dir, 'recipes', self.name)
diff --git a/contrib/apparmor/README.md b/contrib/apparmor/README.md
new file mode 100644
index 000000000000..03e01ca16b6b
--- /dev/null
+++ b/contrib/apparmor/README.md
@@ -0,0 +1,32 @@
+# Electrum AppArmor Profiles
+AppArmor is a Mandatory Access Control (MAC) system which confines programs to a limited set of resources.
+AppArmor confinement is provided via profiles loaded into the kernel.
+
+## Installation
+
+Copy the AppArmor profile from `contrib/apparmor/apparmor.d/` to `/etc/apparmor.d/`:
+```
+sudo cp -R -L contrib/apparmor/apparmor.d/* /etc/apparmor.d
+```
+Reload the AppArmor profiles to apply the changes:
+```
+sudo systemctl reload apparmor
+```
+Verify that the profile is loaded:
+```
+sudo apparmor_status
+```
+Look for the entry corresponding to `electrum`
+
+## Usage
+After installing the AppArmor profile, electrum will be restricted to the permissions specified in the profile.
+
+## Compatibility
+The help tab may not function as expected as browser permissions can be tricky (Tarball Binaries)
+
+These AppArmor profiles have been tested on the following operating systems:
+```
+Debian 12
+Ubuntu 23.10
+Kali Linux 6.6
+```
diff --git a/contrib/apparmor/apparmor.d/abstractions/electrum b/contrib/apparmor/apparmor.d/abstractions/electrum
new file mode 100644
index 000000000000..299718752b06
--- /dev/null
+++ b/contrib/apparmor/apparmor.d/abstractions/electrum
@@ -0,0 +1,43 @@
+include
+include
+include
+include
+include
+include
+include
+include
+include
+include
+include
+include if exists
+include if exists
+include if exists
+include if exists
+
+ owner @{PROC}/@{pid}/{mounts,fd/} r,
+
+ /{usr/,}sbin/ldconfig ix,
+ /{usr/,}bin/{file,dash,dirname,uname} rix,
+ /{usr/,}bin/@{multiarch}-gcc-8 ix,
+ /{usr/,}bin/@{multiarch}-ld.bfd ix,
+ /etc/mime.types r,
+ @{system_share_dirs}/{mime,icons}/{**,} r,
+ /dev/bus/usb/ r,
+ /dev/bus/usb/** rw,
+ @{sys}/class/ r,
+ @{sys}/bus/ r,
+ /etc/udev/udev.conf r,
+ /etc/magic r,
+ @{sys}/devices/pci*/**/usb*/**{busnum,devnum,descriptors,speed,bConfigurationValue} r,
+ /dev/ r,
+ /{var/,}run/udev/data/* r,
+ @{sys}/bus/usb/devices/ r,
+ /{usr/,}/bin/uname rix,
+ owner @{user_share_dirs}/mime/** r,
+
+ /{,run/}user/**/dconf/* rw,
+ /{var/,}lib/dbus/** r,
+ /etc/apt/apt.conf.d/ r,
+ /etc/machine-id r,
+ /{usr/,}bin/xdg-open ix,
+ /{usr/,}bin/evince ix,
diff --git a/contrib/apparmor/apparmor.d/electrum.appimage b/contrib/apparmor/apparmor.d/electrum.appimage
new file mode 100644
index 000000000000..53d0e38449ae
--- /dev/null
+++ b/contrib/apparmor/apparmor.d/electrum.appimage
@@ -0,0 +1,36 @@
+# Credits : Mikhail Morfikov
+abi ,
+
+include
+
+@{exec_path} = /{usr/,}bin/fusermount{,3}
+profile fusermount @{exec_path} {
+ include
+ include
+
+ # To mount anything:
+ # fusermount: mount failed: Operation not permitted
+ capability sys_admin,
+
+ # For jmtpfs
+ capability dac_read_search,
+
+ @{exec_path} mr,
+
+ # Where to mount ISO files
+ owner @{HOME}/*/ rw,
+ owner @{HOME}/*/*/ rw,
+ owner @{HOME}/.cache/**/ rw,
+
+ # Be able to mount ISO images
+ mount fstype={fuse,fuse.*},
+ unmount fstype={fuse,fuse.*},
+
+ /etc/fuse.conf r,
+
+ /dev/fuse rw,
+
+ @{PROC}/@{pid}/mounts r,
+
+ include if exists
+}
diff --git a/contrib/apparmor/apparmor.d/usr.local.bin.electrum b/contrib/apparmor/apparmor.d/usr.local.bin.electrum
new file mode 100644
index 000000000000..281c4c53a880
--- /dev/null
+++ b/contrib/apparmor/apparmor.d/usr.local.bin.electrum
@@ -0,0 +1,15 @@
+#Credits: Anton Nesterov
+abi ,
+
+include
+
+@{electrum_exec_path} = /{usr/,usr/local/,*/*/.local/,}bin/electrum
+
+profile electrum @{electrum_exec_path} {
+ include
+
+ @{electrum_exec_path} mr,
+ owner @{HOME}/.electrum/{**,} rw,
+ owner @{HOME}/.local/{**,} mrw,
+
+}
diff --git a/contrib/ban_unicode.py b/contrib/ban_unicode.py
new file mode 100755
index 000000000000..f4dd4f579aeb
--- /dev/null
+++ b/contrib/ban_unicode.py
@@ -0,0 +1,58 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2025 The Electrum developers
+# Distributed under the MIT software license, see the accompanying
+# file LICENCE or http://www.opensource.org/licenses/mit-license.php
+#
+# This script scans the whole codebase for unicode characters and
+# errors if it finds any, unless the character is specifically whitelisted below.
+# The motivation is to protect against homoglyph attacks, invisible unicode characters,
+# bidirectional and other control characters, and other malicious unicode usage.
+# Given that we mostly expect to use ASCII characters in the source code,
+# the most robust and generic fix seems to be to just ban all unicode usage.
+
+import os.path
+import subprocess
+import sys
+
+project_root = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
+os.chdir(project_root)
+
+EXCLUDE_PATH_PREFIX = {
+ "electrum/wordlist/",
+ "fastlane/",
+ "tests/",
+}
+EXCLUDE_EXTENSIONS = {
+ ".jpg", ".jpeg", ".png", ".ttf", ".otf", ".pdn", ".icns", ".ico", ".gif",
+}
+UNICODE_WHITELIST = {
+ "💬", "🗯", "⚠", chr(0xfe0f), "✓", "▷", "▽", "…", "•", "█", "™", "≈",
+ "á", "é", "’",
+ "│", "─", "└", "├", "📋",
+}
+
+exit_code = 0
+
+bfiles = subprocess.check_output(["git", "ls-files"])
+bfiles = bfiles.decode("utf-8")
+for file_path in bfiles.splitlines():
+ if os.path.isdir(file_path):
+ continue
+ if any(file_path.startswith(pattern) for pattern in EXCLUDE_PATH_PREFIX):
+ continue
+ _fname, ext = os.path.splitext(file_path)
+ if ext in EXCLUDE_EXTENSIONS:
+ continue
+ # open file
+ try:
+ with open(file_path, "r", encoding="utf-8") as f:
+ for line_no, line in enumerate(f.read().splitlines()):
+ for char in line:
+ if ord(char)>0x7f and char not in UNICODE_WHITELIST:
+ print(f"{file_path}:{line_no}. {line=}. hex={hex(ord(char))}. {char=}")
+ exit_code = 1
+ except UnicodeDecodeError as e:
+ raise Exception(f"cannot parse file {file_path=}") from e
+
+sys.exit(exit_code)
diff --git a/contrib/build-linux/appimage/.dockerignore b/contrib/build-linux/appimage/.dockerignore
new file mode 100644
index 000000000000..a4fb4fb1259d
--- /dev/null
+++ b/contrib/build-linux/appimage/.dockerignore
@@ -0,0 +1,2 @@
+build/
+.cache/
diff --git a/contrib/build-linux/appimage/Dockerfile b/contrib/build-linux/appimage/Dockerfile
new file mode 100644
index 000000000000..682c92a4fae6
--- /dev/null
+++ b/contrib/build-linux/appimage/Dockerfile
@@ -0,0 +1,87 @@
+# Note: we deliberately use an old Debian stable as base image.
+# from https://docs.appimage.org/introduction/concepts.html :
+# "[AppImages] should be built on the oldest possible system, allowing them to run on newer system[s]"
+
+FROM debian:bullseye@sha256:cf48c31af360e1c0a0aedd33aae4d928b68c2cdf093f1612650eb1ff434d1c34
+
+ENV LC_ALL=C.UTF-8 LANG=C.UTF-8
+ENV DEBIAN_FRONTEND=noninteractive
+
+# need ca-certificates before using snapshot packages
+RUN apt update -qq > /dev/null && apt install -qq --yes --no-install-recommends \
+ ca-certificates
+
+# pin the distro packages
+COPY apt.sources.list /etc/apt/sources.list
+COPY apt.preferences /etc/apt/preferences.d/snapshot
+
+RUN apt-get update -q && \
+ apt-get install -qy --allow-downgrades \
+ sudo \
+ git \
+ wget \
+ python3 \
+ make \
+ autotools-dev \
+ autoconf \
+ libtool \
+ autopoint \
+ pkg-config \
+ xz-utils \
+ libssl-dev \
+ libssl1.1 \
+ openssl \
+ zlib1g-dev \
+ libffi-dev \
+ libncurses5-dev \
+ libncurses5 \
+ libtinfo-dev \
+ libtinfo5 \
+ libsqlite3-dev \
+ libusb-1.0-0-dev \
+ libudev-dev \
+ libudev1 \
+ gettext \
+ libdbus-1-3 \
+ xutils-dev \
+ libxkbcommon0 \
+ libxkbcommon-x11-0 \
+ libxcb1-dev \
+ libxcb-xinerama0 \
+ libxcb-randr0 \
+ libxcb-render0 \
+ libxcb-shm0 \
+ libxcb-shape0 \
+ libxcb-sync1 \
+ libxcb-xfixes0 \
+ libxcb-xkb1 \
+ libxcb-icccm4 \
+ libxcb-image0 \
+ libxcb-keysyms1 \
+ libxcb-util1 \
+ libxcb-render-util0 \
+ libxcb-cursor0 \
+ libx11-xcb1 \
+ libc6-dev \
+ libc6 \
+ libc-dev-bin \
+ libv4l-dev \
+ libjpeg62-turbo-dev \
+ libx11-dev \
+ desktop-file-utils \
+ && \
+ rm -rf /var/lib/apt/lists/* && \
+ apt-get autoremove -y && \
+ apt-get clean
+
+# create new user to avoid using root; but with sudo access and no password for convenience.
+ARG UID=1000
+RUN useradd -u "$UID" -m -s /usr/bin/bash -d /home/user user
+RUN usermod -aG sudo user
+RUN echo "%sudo ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
+ENV HOME_DIR=/home/user
+ENV WORK_DIR="${HOME_DIR}/wspace"
+ENV PATH="${HOME_DIR}/.local/bin:${PATH}"
+WORKDIR ${WORK_DIR}
+RUN chown -R user ${WORK_DIR}
+USER user
diff --git a/contrib/build-linux/appimage/README.md b/contrib/build-linux/appimage/README.md
new file mode 100644
index 000000000000..7b230d0c5762
--- /dev/null
+++ b/contrib/build-linux/appimage/README.md
@@ -0,0 +1,61 @@
+AppImage binary for Electrum
+============================
+
+✓ _This binary should be reproducible, meaning you should be able to generate
+ binaries that match the official releases._
+
+- _Minimum supported target system (i.e. what end-users need): x86_64, glibc 2.31_
+
+This assumes an Ubuntu host, but it should not be too hard to adapt to another
+similar system. The host architecture should be x86_64 (amd64).
+
+We currently only build a single AppImage, for x86_64 architecture.
+Help to adapt these scripts to build for (some flavor of) ARM would be welcome,
+see [issue #5159](https://github.com/spesmilo/electrum/issues/5159).
+
+
+1. Install Docker
+
+ See [`contrib/docker_notes.md`](../../docker_notes.md).
+
+ (worth reading even if you already have docker)
+
+2. Build binary
+
+ ```
+ $ ./build.sh
+ ```
+ If you want reproducibility, try instead e.g.:
+ ```
+ $ ELECBUILD_COMMIT=HEAD ./build.sh
+ ```
+
+3. The generated binary is in `./dist`.
+
+
+## FAQ
+
+### How can I see what is included in the AppImage?
+Execute the binary as follows: `./electrum*.AppImage --appimage-extract`
+
+### How to investigate diff between binaries if reproducibility fails?
+```
+cd dist/
+./electrum-*-x86_64.AppImage1 --appimage-extract
+mv squashfs-root/ squashfs-root1/
+./electrum-*-x86_64.AppImage2 --appimage-extract
+mv squashfs-root/ squashfs-root2/
+$(cd squashfs-root1; find -type f -exec sha256sum '{}' \; > ./../sha256sum1)
+$(cd squashfs-root2; find -type f -exec sha256sum '{}' \; > ./../sha256sum2)
+diff sha256sum1 sha256sum2 > d
+cat d
+```
+
+For file metadata, e.g. timestamps:
+```
+rsync -n -a -i --delete squashfs-root1/ squashfs-root2/
+```
+
+Useful binary comparison tools:
+- vbindiff
+- diffoscope
diff --git a/contrib/build-linux/appimage/apprun.sh b/contrib/build-linux/appimage/apprun.sh
new file mode 100755
index 000000000000..1343235305a9
--- /dev/null
+++ b/contrib/build-linux/appimage/apprun.sh
@@ -0,0 +1,11 @@
+#!/bin/bash
+
+set -e
+
+APPDIR="$(dirname "$(readlink -e "$0")")"
+
+export LD_LIBRARY_PATH="${APPDIR}/usr/lib/:${APPDIR}/usr/lib/x86_64-linux-gnu${LD_LIBRARY_PATH+:$LD_LIBRARY_PATH}"
+export PATH="${APPDIR}/usr/bin:${PATH}"
+export LDFLAGS="-L${APPDIR}/usr/lib/x86_64-linux-gnu -L${APPDIR}/usr/lib"
+
+exec "${APPDIR}/usr/bin/python3" -s "${APPDIR}/usr/bin/electrum" "$@"
diff --git a/contrib/build-linux/appimage/apt.preferences b/contrib/build-linux/appimage/apt.preferences
new file mode 100644
index 000000000000..d861cd83d960
--- /dev/null
+++ b/contrib/build-linux/appimage/apt.preferences
@@ -0,0 +1,3 @@
+Package: *
+Pin: origin "snapshot.debian.org"
+Pin-Priority: 1001
diff --git a/contrib/build-linux/appimage/apt.sources.list b/contrib/build-linux/appimage/apt.sources.list
new file mode 100644
index 000000000000..30814e600435
--- /dev/null
+++ b/contrib/build-linux/appimage/apt.sources.list
@@ -0,0 +1,2 @@
+deb https://snapshot.debian.org/archive/debian/20250530T143637Z/ bullseye main
+deb-src https://snapshot.debian.org/archive/debian/20250530T143637Z/ bullseye main
diff --git a/contrib/build-linux/appimage/build.sh b/contrib/build-linux/appimage/build.sh
new file mode 100755
index 000000000000..a06f51e41bfb
--- /dev/null
+++ b/contrib/build-linux/appimage/build.sh
@@ -0,0 +1,79 @@
+#!/bin/bash
+#
+# env vars:
+# - ELECBUILD_NOCACHE: if set, forces rebuild of docker image
+# - ELECBUILD_COMMIT: if set, do a fresh clone and git checkout
+
+set -e
+
+PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/../../.."
+PROJECT_ROOT_OR_FRESHCLONE_ROOT="$PROJECT_ROOT"
+CONTRIB="$PROJECT_ROOT/contrib"
+CONTRIB_APPIMAGE="$CONTRIB/build-linux/appimage"
+DISTDIR="$PROJECT_ROOT/dist"
+BUILD_UID=$(/usr/bin/stat -c %u "$PROJECT_ROOT")
+
+. "$CONTRIB"/build_tools_util.sh
+
+
+DOCKER_BUILD_FLAGS=""
+if [ ! -z "$ELECBUILD_NOCACHE" ] ; then
+ info "ELECBUILD_NOCACHE is set. forcing rebuild of docker image."
+ DOCKER_BUILD_FLAGS="--pull --no-cache"
+fi
+
+if [ -z "$ELECBUILD_COMMIT" ] ; then # local dev build
+ DOCKER_BUILD_FLAGS="$DOCKER_BUILD_FLAGS --build-arg UID=$BUILD_UID"
+fi
+
+info "building docker image."
+docker build \
+ $DOCKER_BUILD_FLAGS \
+ -t electrum-appimage-builder-img \
+ "$CONTRIB_APPIMAGE"
+
+# maybe do fresh clone
+if [ ! -z "$ELECBUILD_COMMIT" ] ; then
+ info "ELECBUILD_COMMIT=$ELECBUILD_COMMIT. doing fresh clone and git checkout."
+ FRESH_CLONE="/tmp/electrum_build/appimage/fresh_clone/electrum"
+ rm -rf "$FRESH_CLONE" 2>/dev/null || ( info "we need sudo to rm prev FRESH_CLONE." && sudo rm -rf "$FRESH_CLONE" )
+ umask 0022
+ git clone "$PROJECT_ROOT" "$FRESH_CLONE"
+ cd "$FRESH_CLONE"
+ git checkout "$ELECBUILD_COMMIT"
+ PROJECT_ROOT_OR_FRESHCLONE_ROOT="$FRESH_CLONE"
+else
+ info "not doing fresh clone."
+fi
+
+# build the type2-runtime binary, this build step uses a separate docker container
+# defined in the type2-runtime repo (patched with type2-runtime-reproducible-build.patch)
+"$PROJECT_ROOT_OR_FRESHCLONE_ROOT/contrib/build-linux/appimage/make_type2_runtime.sh" || fail "Error building type2-runtime."
+
+DOCKER_RUN_FLAGS=""
+if sh -c ": >/dev/tty" >/dev/null 2>/dev/null; then
+ info "/dev/tty is available and usable"
+ DOCKER_RUN_FLAGS="-it"
+fi
+
+info "building binary..."
+# check uid and maybe chown. see #8261
+if [ ! -z "$ELECBUILD_COMMIT" ] ; then # fresh clone (reproducible build)
+ if [ $(id -u) != "1000" ] || [ $(id -g) != "1000" ] ; then
+ info "need to chown -R FRESH_CLONE dir. prompting for sudo."
+ sudo chown -R 1000:1000 "$FRESH_CLONE"
+ fi
+fi
+docker run $DOCKER_RUN_FLAGS \
+ --name electrum-appimage-builder-cont \
+ -v "$PROJECT_ROOT_OR_FRESHCLONE_ROOT":/opt/electrum \
+ --rm \
+ --workdir /opt/electrum/contrib/build-linux/appimage \
+ electrum-appimage-builder-img \
+ ./make_appimage.sh
+
+# make sure resulting binary location is independent of fresh_clone
+if [ ! -z "$ELECBUILD_COMMIT" ] ; then
+ mkdir --parents "$DISTDIR/"
+ cp -f "$FRESH_CLONE/dist"/* "$DISTDIR/"
+fi
diff --git a/contrib/build-linux/appimage/make_appimage.sh b/contrib/build-linux/appimage/make_appimage.sh
new file mode 100755
index 000000000000..f10ef526b3fa
--- /dev/null
+++ b/contrib/build-linux/appimage/make_appimage.sh
@@ -0,0 +1,280 @@
+#!/bin/bash
+
+set -e
+
+PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/../../.."
+CONTRIB="$PROJECT_ROOT/contrib"
+CONTRIB_APPIMAGE="$CONTRIB/build-linux/appimage"
+DISTDIR="$PROJECT_ROOT/dist"
+BUILDDIR="$CONTRIB_APPIMAGE/build/appimage"
+APPDIR="$BUILDDIR/electrum.AppDir"
+CACHEDIR="$CONTRIB_APPIMAGE/.cache/appimage"
+TYPE2_RUNTIME_REPO_DIR="$CACHEDIR/type2-runtime"
+export DLL_TARGET_DIR="$CACHEDIR/dlls"
+PIP_CACHE_DIR="$CONTRIB_APPIMAGE/.cache/pip_cache"
+
+. "$CONTRIB"/build_tools_util.sh
+
+git -C "$PROJECT_ROOT" rev-parse 2>/dev/null || fail "Building outside a git clone is not supported."
+
+export GCC_STRIP_BINARIES="1"
+
+# pinned versions
+PYTHON_VERSION=3.12.11
+PY_VER_MAJOR="3.12" # as it appears in fs paths
+PKG2APPIMAGE_COMMIT="a9c85b7e61a3a883f4a35c41c5decb5af88b6b5d"
+
+VERSION=$(git describe --tags --dirty --always)
+APPIMAGE="$DISTDIR/electrum-$VERSION-x86_64.AppImage"
+
+rm -rf "$BUILDDIR"
+mkdir -p "$APPDIR" "$CACHEDIR" "$PIP_CACHE_DIR" "$DISTDIR" "$DLL_TARGET_DIR"
+
+# potential leftover from setuptools that might make pip put garbage in binary
+rm -rf "$PROJECT_ROOT/build"
+
+
+info "downloading some dependencies."
+download_if_not_exist "$CACHEDIR/functions.sh" "https://raw.githubusercontent.com/AppImage/pkg2appimage/$PKG2APPIMAGE_COMMIT/functions.sh"
+verify_hash "$CACHEDIR/functions.sh" "8f67711a28635b07ce539a9b083b8c12d5488c00003d6d726c7b134e553220ed"
+
+download_if_not_exist "$CACHEDIR/appimagetool" "https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage"
+verify_hash "$CACHEDIR/appimagetool" "46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1"
+# note: desktop-file-utils in the docker image is needed to run desktop-file-validate for appimagetool <= 1.9.0, so it can be removed once
+# appimagetool tags a new release (see https://github.com/AppImage/appimagetool/pull/47)
+
+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" "c30bb24b7f1e9a19b11b55a546434f74e739bb4c271a3e3a80ff4380d49f7adb"
+
+
+
+info "building python."
+tar xf "$CACHEDIR/Python-$PYTHON_VERSION.tar.xz" -C "$CACHEDIR"
+(
+ if [ -f "$CACHEDIR/Python-$PYTHON_VERSION/python" ]; then
+ info "python already built, skipping"
+ exit 0
+ fi
+ cd "$CACHEDIR/Python-$PYTHON_VERSION"
+ LC_ALL=C export BUILD_DATE=$(date -u -d "@$SOURCE_DATE_EPOCH" "+%b %d %Y")
+ LC_ALL=C export BUILD_TIME=$(date -u -d "@$SOURCE_DATE_EPOCH" "+%H:%M:%S")
+ # Patches taken from Ubuntu http://archive.ubuntu.com/ubuntu/pool/main/p/python3.11/python3.11_3.11.6-3.debian.tar.xz
+ patch -p1 < "$CONTRIB_APPIMAGE/patches/python-3.11-reproducible-buildinfo.diff"
+ ./configure \
+ --cache-file="$CACHEDIR/python.config.cache" \
+ --prefix="$APPDIR/usr" \
+ --enable-ipv6 \
+ --enable-shared \
+ -q
+ make "-j$CPU_COUNT" -s || fail "Could not build Python"
+)
+info "installing python."
+(
+ cd "$CACHEDIR/Python-$PYTHON_VERSION"
+ make -s install > /dev/null || fail "Could not install Python"
+ # When building in docker on macOS, python builds with .exe extension because the
+ # case insensitive file system of macOS leaks into docker. This causes the build
+ # to result in a different output on macOS compared to Linux. We simply patch
+ # sysconfigdata to remove the extension.
+ # Some more info: https://bugs.python.org/issue27631
+ sed -i -e 's/\.exe//g' "${APPDIR}/usr/lib/python${PY_VER_MAJOR}"/_sysconfigdata*
+)
+
+
+if ls "$DLL_TARGET_DIR"/libsecp256k1.so.* 1> /dev/null 2>&1; then
+ info "libsecp256k1 already built, skipping"
+else
+ "$CONTRIB"/make_libsecp256k1.sh || fail "Could not build libsecp"
+fi
+cp -f "$DLL_TARGET_DIR"/libsecp256k1.so.* "$APPDIR/usr/lib/" || fail "Could not copy libsecp to its destination"
+
+
+if [ -f "$DLL_TARGET_DIR/libzbar.so.0" ]; then
+ info "libzbar already built, skipping"
+else
+ # note: could instead just use the libzbar0 pkg from debian/apt, but that is too old and missing fixes for CVE-2023-40889
+ "$CONTRIB"/make_zbar.sh || fail "Could not build zbar"
+fi
+cp -f "$DLL_TARGET_DIR/libzbar.so.0" "$APPDIR/usr/lib/" || fail "Could not copy libzbar to its destination"
+
+
+appdir_python() {
+ env \
+ PYTHONNOUSERSITE=1 \
+ LD_LIBRARY_PATH="$APPDIR/usr/lib:$APPDIR/usr/lib/x86_64-linux-gnu${LD_LIBRARY_PATH+:$LD_LIBRARY_PATH}" \
+ "$APPDIR/usr/bin/python${PY_VER_MAJOR}" "$@"
+}
+
+python='appdir_python'
+
+
+info "installing pip."
+"$python" -m ensurepip
+
+break_legacy_easy_install
+
+
+info "preparing electrum-locale."
+(
+ "$CONTRIB/locale/build_cleanlocale.sh"
+ # we want the binary to have only compiled (.mo) locale files; not source (.po) files
+ rm -r "$PROJECT_ROOT/electrum/locale/locale"/*/electrum.po
+)
+
+
+info "Installing build dependencies."
+# note: re pip installing from PyPI,
+# we prefer compiling C extensions ourselves, instead of using binary wheels,
+# hence "--no-binary :all:" flags. However, we specifically allow
+# - PyQt6, as it's harder to build from source
+# - cryptography, as it's harder to build from source
+# - the whole of "requirements-build-base.txt", which includes pip and friends, as it also includes "wheel",
+# and I am not quite sure how to break the circular dependence there (I guess we could introduce
+# "requirements-build-base-base.txt" with just wheel in it...)
+"$python" -m pip install --no-build-isolation --no-dependencies --no-warn-script-location \
+ --cache-dir "$PIP_CACHE_DIR" -r "$CONTRIB/deterministic-build/requirements-build-base.txt"
+"$python" -m pip install --no-build-isolation --no-dependencies --no-binary :all: --no-warn-script-location \
+ --cache-dir "$PIP_CACHE_DIR" -r "$CONTRIB/deterministic-build/requirements-build-appimage.txt"
+
+
+# opt out of compiling C extensions
+export YARL_NO_EXTENSIONS=1
+export FROZENLIST_NO_EXTENSIONS=1
+
+export ELECTRUM_ECC_DONT_COMPILE=1
+
+info "installing electrum and its dependencies."
+"$python" -m pip install --no-build-isolation --no-dependencies --no-binary :all: --no-warn-script-location \
+ --cache-dir "$PIP_CACHE_DIR" -r "$CONTRIB/deterministic-build/requirements.txt"
+"$python" -m pip install --no-build-isolation --no-dependencies --no-binary :all: --only-binary PyQt6,PyQt6-Qt6,cryptography --no-warn-script-location \
+ --cache-dir "$PIP_CACHE_DIR" -r "$CONTRIB/deterministic-build/requirements-binaries.txt"
+"$python" -m pip install --no-build-isolation --no-dependencies --no-binary :all: --no-warn-script-location \
+ --cache-dir "$PIP_CACHE_DIR" -r "$CONTRIB/deterministic-build/requirements-hw.txt"
+
+"$python" -m pip install --no-build-isolation --no-dependencies --no-warn-script-location \
+ --cache-dir "$PIP_CACHE_DIR" "$PROJECT_ROOT"
+
+# was only needed during build time, not runtime
+"$python" -m pip uninstall -y Cython
+
+
+info "desktop integration."
+cp "$PROJECT_ROOT/electrum.desktop" "$APPDIR/electrum.desktop"
+cp "$PROJECT_ROOT/electrum/gui/icons/electrum.png" "$APPDIR/electrum.png"
+
+
+# add launcher
+cp "$CONTRIB_APPIMAGE/apprun.sh" "$APPDIR/AppRun"
+
+info "finalizing AppDir."
+(
+ export PKG2AICOMMIT="$PKG2APPIMAGE_COMMIT"
+ . "$CACHEDIR/functions.sh"
+
+ cd "$APPDIR"
+ # copy system dependencies
+ copy_deps; copy_deps; copy_deps
+ move_lib
+
+ # apply global appimage blacklist to exclude stuff
+ # move usr/include out of the way to preserve usr/include/python${PY_VER_MAJOR}.
+ mv usr/include usr/include.tmp
+ delete_blacklisted
+ mv usr/include.tmp usr/include
+)
+
+info "Copying additional libraries"
+(
+ # On some systems it can cause problems to use the system libusb (on AppImage excludelist)
+ cp -f /usr/lib/x86_64-linux-gnu/libusb-1.0.so "$APPDIR/usr/lib/libusb-1.0.so" || fail "Could not copy libusb"
+ # some distros lack libxkbcommon-x11
+ cp -f /usr/lib/x86_64-linux-gnu/libxkbcommon-x11.so.0 "$APPDIR"/usr/lib/x86_64-linux-gnu || fail "Could not copy libxkbcommon-x11"
+ # some distros lack some libxcb libraries (see https://github.com/Electron-Cash/Electron-Cash/issues/2196)
+ cp -f /usr/lib/x86_64-linux-gnu/libxcb-* "$APPDIR"/usr/lib/x86_64-linux-gnu || fail "Could not copy libxcb"
+)
+
+info "stripping binaries from debug symbols."
+# "-R .note.gnu.build-id" also strips the build id
+# "-R .comment" also strips the GCC version information
+strip_binaries()
+{
+ chmod u+w -R "$APPDIR"
+ {
+ printf '%s\0' "$APPDIR/usr/bin/python${PY_VER_MAJOR}"
+ find "$APPDIR" -type f -regex '.*\.so\(\.[0-9.]+\)?$' -print0
+ } | xargs -0 --no-run-if-empty --verbose strip -R .note.gnu.build-id -R .comment
+}
+strip_binaries
+
+remove_emptydirs()
+{
+ find "$APPDIR" -type d -empty -print0 | xargs -0 --no-run-if-empty rmdir -vp --ignore-fail-on-non-empty
+}
+remove_emptydirs
+
+
+info "removing some unneeded stuff to decrease binary size."
+rm -rf "$APPDIR"/usr/{share,include}
+PYDIR="$APPDIR/usr/lib/python${PY_VER_MAJOR}"
+rm -rf "$PYDIR"/{test,ensurepip,lib2to3,idlelib,turtledemo}
+rm -rf "$PYDIR"/{ctypes,sqlite3,tkinter,unittest}/test
+rm -rf "$PYDIR"/distutils/{command,tests}
+rm -rf "$PYDIR"/config-3.*-x86_64-linux-gnu
+rm -rf "$PYDIR"/site-packages/{opt,pip,setuptools,wheel}
+rm -rf "$PYDIR"/site-packages/Cryptodome/SelfTest
+rm -rf "$PYDIR"/site-packages/{psutil,qrcode,websocket}/tests
+# rm lots of unused parts of Qt/PyQt. (assuming PyQt 6 layout)
+for component in connectivity declarative help location multimedia quickcontrols2 serialport webengine websockets xmlpatterns ; do
+ rm -rf "$PYDIR"/site-packages/PyQt6/Qt6/translations/qt${component}_*
+ rm -rf "$PYDIR"/site-packages/PyQt6/Qt6/resources/qt${component}_*
+done
+rm -rf "$PYDIR"/site-packages/PyQt6/Qt6/{qml,libexec}
+rm -rf "$PYDIR"/site-packages/PyQt6/{pyrcc*.so,pylupdate*.so,uic}
+rm -rf "$PYDIR"/site-packages/PyQt6/Qt6/plugins/{bearer,gamepads,geometryloaders,geoservices,playlistformats,position,renderplugins,sceneparsers,sensors,sqldrivers,texttospeech,webview}
+for component in Bluetooth Concurrent Designer Help Location NetworkAuth Nfc Positioning PositioningQuick Qml Quick Sensors SerialPort Sql Test Web Xml Labs ShaderTools SpatialAudio ; do
+ rm -rf "$PYDIR"/site-packages/PyQt6/Qt6/lib/libQt6${component}*
+ rm -rf "$PYDIR"/site-packages/PyQt6/Qt${component}*
+ rm -rf "$PYDIR"/site-packages/PyQt6/bindings/Qt${component}*
+done
+for component in Qml Quick ; do
+ rm -rf "$PYDIR"/site-packages/PyQt6/Qt6/lib/libQt6*${component}.so*
+done
+rm -rf "$PYDIR"/site-packages/PyQt6/Qt.so
+
+# these are deleted as they were not deterministic; and are not needed anyway
+find "$APPDIR" -path '*/__pycache__*' -delete
+# although note that *.dist-info might be needed by certain packages...
+# e.g. slip10 uses importlib that needs it
+for f in "$PYDIR"/site-packages/{slip10,trezor}-*.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/{slip10,trezor}-*.dist-info2; do mv "$f" "$(echo "$f" | sed s/\.dist-info2/\.dist-info/)"; done
+
+
+find -exec touch -h -d '2000-11-11T11:11:11+00:00' {} +
+
+
+info "creating the AppImage."
+(
+ cd "$BUILDDIR"
+ cp "$CACHEDIR/appimagetool" "$CACHEDIR/appimagetool_copy"
+ # zero out "appimage" magic bytes, as on some systems they confuse the linker
+ sed -i 's|AI\x02|\x00\x00\x00|' "$CACHEDIR/appimagetool_copy"
+ chmod +x "$CACHEDIR/appimagetool_copy"
+ "$CACHEDIR/appimagetool_copy" --appimage-extract
+ # We build a small wrapper for mksquashfs that removes the -mkfs-time option
+ # as it conflicts with SOURCE_DATE_EPOCH.
+ mv "$BUILDDIR/squashfs-root/usr/bin/mksquashfs" "$BUILDDIR/squashfs-root/usr/bin/mksquashfs_orig"
+ cat > "$BUILDDIR/squashfs-root/usr/bin/mksquashfs" << EOF
+#!/bin/sh
+args=\$(echo "\$@" | sed -e 's/-mkfs-time 0//')
+"$BUILDDIR/squashfs-root/usr/bin/mksquashfs_orig" \$args
+EOF
+ chmod +x "$BUILDDIR/squashfs-root/usr/bin/mksquashfs"
+ env VERSION="$VERSION" ARCH=x86_64 ./squashfs-root/AppRun --runtime-file "$TYPE2_RUNTIME_REPO_DIR/runtime-x86_64" --no-appstream --verbose "$APPDIR" "$APPIMAGE"
+)
+
+
+info "done."
+ls -la "$DISTDIR"
+sha256sum "$DISTDIR"/*
diff --git a/contrib/build-linux/appimage/make_type2_runtime.sh b/contrib/build-linux/appimage/make_type2_runtime.sh
new file mode 100755
index 000000000000..a17bc35ab780
--- /dev/null
+++ b/contrib/build-linux/appimage/make_type2_runtime.sh
@@ -0,0 +1,36 @@
+#!/bin/bash
+
+set -e
+
+PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/../../.."
+CONTRIB="$PROJECT_ROOT/contrib"
+CONTRIB_APPIMAGE="$CONTRIB/build-linux/appimage"
+
+# when bumping the runtime commit also check if the `type2-runtime-reproducible-build.patch` still works
+TYPE2_RUNTIME_COMMIT="5e7217b7cfeecee1491c2d251e355c3cf8ba6e4d"
+TYPE2_RUNTIME_REPO="https://github.com/AppImage/type2-runtime.git"
+
+. "$CONTRIB"/build_tools_util.sh
+
+
+TYPE2_RUNTIME_REPO_DIR="$PROJECT_ROOT/contrib/build-linux/appimage/.cache/appimage/type2-runtime"
+if [ -f "$TYPE2_RUNTIME_REPO_DIR/runtime-x86_64" ]; then
+ info "type2-runtime already built, skipping"
+ exit 0
+fi
+clone_or_update_repo "$TYPE2_RUNTIME_REPO" "$TYPE2_RUNTIME_COMMIT" "$TYPE2_RUNTIME_REPO_DIR"
+
+# Apply patch to make runtime build reproducible
+info "Applying type2-runtime patch..."
+cd "$TYPE2_RUNTIME_REPO_DIR"
+git apply "$CONTRIB_APPIMAGE/patches/type2-runtime-reproducible-build.patch" || fail "Failed to apply runtime repo patch"
+
+info "building type2-runtime in build container..."
+cd "$TYPE2_RUNTIME_REPO_DIR/scripts/docker"
+env ARCH=x86_64 ./build-with-docker.sh
+mv "./runtime-x86_64" "$TYPE2_RUNTIME_REPO_DIR/"
+
+# clean up the empty created 'out' dir to prevent permission issues
+rm -rf "$TYPE2_RUNTIME_REPO_DIR/out"
+
+info "runtime build successful: $(sha256sum "$TYPE2_RUNTIME_REPO_DIR/runtime-x86_64")"
diff --git a/contrib/build-linux/appimage/patches/python-3.11-reproducible-buildinfo.diff b/contrib/build-linux/appimage/patches/python-3.11-reproducible-buildinfo.diff
new file mode 100644
index 000000000000..5ed726580acd
--- /dev/null
+++ b/contrib/build-linux/appimage/patches/python-3.11-reproducible-buildinfo.diff
@@ -0,0 +1,17 @@
+Description: Build reproduceable date and time into build info
+ Build information is encoded into getbuildinfo.o at build time.
+ Use the date and time from the debian changelog, to make this reproduceable.
+
+Forwarded: no
+
+--- a/Makefile.pre.in
++++ b/Makefile.pre.in
+@@ -1248,6 +1248,8 @@
+ -DGITVERSION="\"`LC_ALL=C $(GITVERSION)`\"" \
+ -DGITTAG="\"`LC_ALL=C $(GITTAG)`\"" \
+ -DGITBRANCH="\"`LC_ALL=C $(GITBRANCH)`\"" \
++ $(if $(BUILD_DATE),-DDATE='"$(BUILD_DATE)"') \
++ $(if $(BUILD_TIME),-DTIME='"$(BUILD_TIME)"') \
+ -o $@ $(srcdir)/Modules/getbuildinfo.c
+
+ Modules/getpath.o: $(srcdir)/Modules/getpath.c Python/frozen_modules/getpath.h Makefile $(PYTHON_HEADERS)
diff --git a/contrib/build-linux/appimage/patches/type2-runtime-reproducible-build.patch b/contrib/build-linux/appimage/patches/type2-runtime-reproducible-build.patch
new file mode 100644
index 000000000000..3192d9289e10
--- /dev/null
+++ b/contrib/build-linux/appimage/patches/type2-runtime-reproducible-build.patch
@@ -0,0 +1,149 @@
+From 0c54d91dd1d33235ae97566600e692edfb613642 Mon Sep 17 00:00:00 2001
+From: f321x
+Date: Thu, 10 Jul 2025 17:45:20 +0200
+Subject: [PATCH] make docker build reproducible
+
+attempts to make the docker build more reproducible by:
+* pinning the docker image (alpine:3.21) to a hash
+* version pinning the apk packages in the dockerfile
+* setting TZ, LC_ALL and SOURCE_DATE_EPOCH in the container
+* only building single threaded (make -j1)
+* use a fixed build directory in `build-runtime.sh` instead of mktemp
+* prevent linker from adding build id (-Wl,--build-id=none)
+* replace absolute build paths in debug info with relative paths
+ (-fdebug-prefix-map=$(PWD)=.)
+* replace absolute paths in all compiler output with relative paths
+ (-ffile-prefix-map=$(PWD)=.)
+* stop adding gnu-debuglink to runtime binary
+---
+ scripts/build-runtime.sh | 18 +++++++++++----
+ scripts/common/install-dependencies.sh | 2 +-
+ scripts/docker/Dockerfile | 32 ++++++++++++++++++++++----
+ src/runtime/Makefile | 2 +-
+ 4 files changed, 42 insertions(+), 12 deletions(-)
+
+diff --git a/scripts/build-runtime.sh b/scripts/build-runtime.sh
+index 3ce3b91..e11f082 100755
+--- a/scripts/build-runtime.sh
++++ b/scripts/build-runtime.sh
+@@ -8,8 +8,10 @@ set -euo pipefail
+ out_dir="$(readlink -f "$(pwd)")"/out
+ mkdir -p "$out_dir"
+
+-# we create a temporary build directory
+-build_dir="$(mktemp -d -t type2-runtime-build-XXXXXX)"
++# we create a temporary build directory with a fixed name for reproducibility
++build_dir="$(readlink -f "$(pwd)")"/build-runtime-temp
++rm -rf "$build_dir"
++mkdir -p "$build_dir"
+
+ # since the plain ol' Makefile doesn't support out-of-source builds at all, we need to copy all the files
+ cp -R src "$build_dir"/
+@@ -17,13 +19,14 @@ cp -R src "$build_dir"/
+ pushd "$build_dir"
+
+ pushd src/runtime/
+-make -j"$(nproc)" runtime
++make -j1 runtime
+
+ file runtime
+
+ objcopy --only-keep-debug runtime runtime.debug
+
+-strip --strip-debug --strip-unneeded runtime
++# strip --strip-debug --strip-unneeded runtime
++strip --strip-all runtime
+
+ ls -lh runtime runtime.debug
+
+@@ -50,7 +53,7 @@ fi
+ mv runtime runtime-"$architecture"
+ mv runtime.debug runtime-"$architecture".debug
+
+-objcopy --add-gnu-debuglink runtime-"$architecture".debug runtime-"$architecture"
++# objcopy --add-gnu-debuglink runtime-"$architecture".debug runtime-"$architecture"
+
+ # "classic" magic bytes which cannot be embedded with compiler magic, always do AFTER strip
+ # needs to be done after calls to objcopy, strip etc.
+@@ -61,3 +64,8 @@ cp runtime-"$architecture" "$out_dir"/
+ cp runtime-"$architecture".debug "$out_dir"/
+
+ ls -al "$out_dir"
++
++# cleanup
++popd # return to build_dir
++popd # return to original working directory
++rm -rf "$build_dir"
+diff --git a/scripts/common/install-dependencies.sh b/scripts/common/install-dependencies.sh
+index 0e21cdb..5237079 100755
+--- a/scripts/common/install-dependencies.sh
++++ b/scripts/common/install-dependencies.sh
+@@ -39,7 +39,7 @@ tar xf 0.5.2.tar.gz
+ pushd squashfuse-*/
+ ./autogen.sh
+ ./configure LDFLAGS="-static"
+-make -j"$(nproc)"
++make -j1
+ make install
+ /usr/bin/install -c -m 644 ./*.h '/usr/local/include/squashfuse'
+ popd
+diff --git a/scripts/docker/Dockerfile b/scripts/docker/Dockerfile
+index 07b6533..fba9c6e 100644
+--- a/scripts/docker/Dockerfile
++++ b/scripts/docker/Dockerfile
+@@ -1,13 +1,35 @@
+-FROM alpine:3.21
++FROM alpine:3.21@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c
+
+ # includes dependencies from https://git.alpinelinux.org/aports/tree/main/fuse3/APKBUILD
+ RUN apk add --no-cache \
+- bash alpine-sdk util-linux strace file autoconf automake libtool xz \
+- eudev-dev gettext-dev linux-headers meson \
+- zstd-dev zstd-static zlib-dev zlib-static clang musl-dev mimalloc-dev
++ bash=5.2.37-r0 \
++ alpine-sdk=1.1-r0 \
++ util-linux=2.40.4-r1 \
++ strace=6.12-r0 \
++ file=5.46-r2 \
++ autoconf=2.72-r0 \
++ automake=1.17-r0 \
++ libtool=2.4.7-r3 \
++ xz=5.8.3-r0 \
++ eudev-dev=3.2.14-r5 \
++ gettext-dev=0.22.5-r0 \
++ linux-headers=6.6-r1 \
++ meson=1.6.1-r0 \
++ zstd-dev=1.5.6-r2 \
++ zstd-static=1.5.6-r2 \
++ zlib-dev=1.3.2-r0 \
++ zlib-static=1.3.2-r0 \
++ clang19=19.1.4-r0 \
++ musl-dev=1.2.5-r11 \
++ mimalloc2-dev=2.1.7-r0
+
+ COPY scripts/common/install-dependencies.sh /tmp/scripts/common/install-dependencies.sh
+ COPY patches/ /tmp/patches/
+
++# Set environment variables for reproducible build
++ENV SOURCE_DATE_EPOCH=1640995200
++ENV TZ=UTC
++ENV LC_ALL=C
++
+ WORKDIR /tmp
+-RUN bash scripts/common/install-dependencies.sh
++RUN bash scripts/common/install-dependencies.sh
+\ No newline at end of file
+diff --git a/src/runtime/Makefile b/src/runtime/Makefile
+index 9fd4165..3a3cbaa 100644
+--- a/src/runtime/Makefile
++++ b/src/runtime/Makefile
+@@ -1,6 +1,6 @@
+ GIT_COMMIT := $(shell cat version)
+ CC = clang
+-CFLAGS = -std=gnu99 -Os -D_FILE_OFFSET_BITS=64 -DGIT_COMMIT=\"$(GIT_COMMIT)\" -T data_sections.ld -ffunction-sections -fdata-sections -Wl,--gc-sections -static -Wall -Werror -static-pie
++CFLAGS = -std=gnu99 -Os -D_FILE_OFFSET_BITS=64 -DGIT_COMMIT=\"$(GIT_COMMIT)\" -T data_sections.ld -ffunction-sections -fdata-sections -Wl,--gc-sections -Wl,--build-id=none -static -Wall -Werror -static-pie -fdebug-prefix-map=$(PWD)=. -ffile-prefix-map=$(PWD)=.
+ LIBS = -lsquashfuse -lsquashfuse_ll -lzstd -lz -lfuse3 -lmimalloc
+
+ all: runtime
+--
+2.50.0
diff --git a/lib/tests/__init__.py b/contrib/build-linux/sdist/.dockerignore
similarity index 100%
rename from lib/tests/__init__.py
rename to contrib/build-linux/sdist/.dockerignore
diff --git a/contrib/build-linux/sdist/Dockerfile b/contrib/build-linux/sdist/Dockerfile
new file mode 100644
index 000000000000..4c284e4ab6f1
--- /dev/null
+++ b/contrib/build-linux/sdist/Dockerfile
@@ -0,0 +1,29 @@
+FROM debian:bookworm@sha256:b877a1a3fdf02469440f1768cf69c9771338a875b7add5e80c45b756c92ac20a
+
+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 \
+ python3-venv \
+ && \
+ rm -rf /var/lib/apt/lists/* && \
+ apt-get autoremove -y && \
+ apt-get clean
+
+# create new user to avoid using root; but with sudo access and no password for convenience.
+ARG UID=1000
+RUN useradd -u "$UID" -m -s /usr/bin/bash -d /home/user user
+RUN usermod -aG sudo user
+RUN echo "%sudo ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
+ENV HOME_DIR=/home/user
+ENV WORK_DIR="${HOME_DIR}/wspace"
+ENV PATH="${HOME_DIR}/.local/bin:${PATH}"
+WORKDIR ${WORK_DIR}
+RUN chown -R user ${WORK_DIR}
+USER user
diff --git a/contrib/build-linux/sdist/README.md b/contrib/build-linux/sdist/README.md
new file mode 100644
index 000000000000..96c203cf09a9
--- /dev/null
+++ b/contrib/build-linux/sdist/README.md
@@ -0,0 +1,55 @@
+# Source tarballs
+
+✓ _These tarballs 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.
+
+We distribute two tarballs, a "normal" one (the default, recommended for users),
+and a strictly source-only one (for Linux distro packagers).
+The normal tarball, in addition to including everything from
+the source-only one, also includes:
+- compiled (`.mo`) locale files (in addition to source `.po` locale files)
+- compiled (`_pb2.py`) protobuf files (in addition to source `.proto` files)
+- the `packages/` folder containing source-only pure-python runtime dependencies
+
+
+## Build steps
+
+1. Install Docker
+
+ See [`contrib/docker_notes.md`](../../docker_notes.md).
+
+ (worth reading even if you already have docker)
+
+2. Build tarball
+
+ (set envvar `OMIT_UNCLEAN_FILES=1` to build the "source-only" tarball)
+ ```
+ $ ./build.sh
+ ```
+ If you want reproducibility, try instead e.g.:
+ ```
+ $ ELECBUILD_COMMIT=HEAD ELECBUILD_NOCACHE=1 ./build.sh
+ $ ELECBUILD_COMMIT=HEAD ELECBUILD_NOCACHE=1 OMIT_UNCLEAN_FILES=1 ./build.sh
+ ```
+
+3. The generated distributables are in `./dist`.
+
+
+## Differences between the `sourceonly` vs "normal" tar.gz
+
+These scripts can either build a source-only or a "normal" tarball.
+The official release process builds both.
+
+The source-only tarball is aimed at Linux distro packagers.
+Users wanting to run from source should typically use the normal tarball.
+
+The differences are as follows:
+- the normal tarball bundles all the pure-python dependencies of Electrum.
+ These are placed into the `packages/` folder, and they are automatically
+ found and used at runtime.
+- the normal tarball includes compiled (.mo) locale files, the source-only tarball does not.
+ Both tarballs contain (.po) source locale files. If you are packaging for a Linux distro,
+ you probably want to compile the .mo locale files yourself (see `contrib/locale/build_locale.sh`).
diff --git a/contrib/build-linux/sdist/build.sh b/contrib/build-linux/sdist/build.sh
new file mode 100755
index 000000000000..aa68e08f63f3
--- /dev/null
+++ b/contrib/build-linux/sdist/build.sh
@@ -0,0 +1,76 @@
+#!/bin/bash
+#
+# env vars:
+# - ELECBUILD_NOCACHE: if set, forces rebuild of docker image
+# - ELECBUILD_COMMIT: if set, do a fresh clone and git checkout
+
+set -e
+
+PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/../../.."
+PROJECT_ROOT_OR_FRESHCLONE_ROOT="$PROJECT_ROOT"
+CONTRIB="$PROJECT_ROOT/contrib"
+CONTRIB_SDIST="$CONTRIB/build-linux/sdist"
+DISTDIR="$PROJECT_ROOT/dist"
+BUILD_UID=$(/usr/bin/stat -c %u "$PROJECT_ROOT")
+
+. "$CONTRIB"/build_tools_util.sh
+
+
+DOCKER_BUILD_FLAGS=""
+if [ ! -z "$ELECBUILD_NOCACHE" ] ; then
+ info "ELECBUILD_NOCACHE is set. forcing rebuild of docker image."
+ DOCKER_BUILD_FLAGS="--pull --no-cache"
+fi
+
+if [ -z "$ELECBUILD_COMMIT" ] ; then # local dev build
+ DOCKER_BUILD_FLAGS="$DOCKER_BUILD_FLAGS --build-arg UID=$BUILD_UID"
+fi
+
+info "building docker image."
+docker build \
+ $DOCKER_BUILD_FLAGS \
+ -t electrum-sdist-builder-img \
+ "$CONTRIB_SDIST"
+
+# maybe do fresh clone
+if [ ! -z "$ELECBUILD_COMMIT" ] ; then
+ info "ELECBUILD_COMMIT=$ELECBUILD_COMMIT. doing fresh clone and git checkout."
+ FRESH_CLONE="/tmp/electrum_build/sdist/fresh_clone/electrum"
+ rm -rf "$FRESH_CLONE" 2>/dev/null || ( info "we need sudo to rm prev FRESH_CLONE." && sudo rm -rf "$FRESH_CLONE" )
+ umask 0022
+ git clone "$PROJECT_ROOT" "$FRESH_CLONE"
+ cd "$FRESH_CLONE"
+ git checkout "$ELECBUILD_COMMIT"
+ PROJECT_ROOT_OR_FRESHCLONE_ROOT="$FRESH_CLONE"
+else
+ info "not doing fresh clone."
+fi
+
+DOCKER_RUN_FLAGS=""
+if sh -c ": >/dev/tty" >/dev/null 2>/dev/null; then
+ info "/dev/tty is available and usable"
+ DOCKER_RUN_FLAGS="-it"
+fi
+
+info "building binary..."
+# check uid and maybe chown. see #8261
+if [ ! -z "$ELECBUILD_COMMIT" ] ; then # fresh clone (reproducible build)
+ if [ $(id -u) != "1000" ] || [ $(id -g) != "1000" ] ; then
+ info "need to chown -R FRESH_CLONE dir. prompting for sudo."
+ sudo chown -R 1000:1000 "$FRESH_CLONE"
+ fi
+fi
+docker run $DOCKER_RUN_FLAGS \
+ --name electrum-sdist-builder-cont \
+ -v "$PROJECT_ROOT_OR_FRESHCLONE_ROOT":/opt/electrum \
+ --rm \
+ --workdir /opt/electrum/contrib/build-linux/sdist \
+ --env OMIT_UNCLEAN_FILES \
+ electrum-sdist-builder-img \
+ ./make_sdist.sh
+
+# make sure resulting binary location is independent of fresh_clone
+if [ ! -z "$ELECBUILD_COMMIT" ] ; then
+ mkdir --parents "$DISTDIR/"
+ cp -f "$FRESH_CLONE/dist"/* "$DISTDIR/"
+fi
diff --git a/contrib/build-linux/sdist/make_sdist.sh b/contrib/build-linux/sdist/make_sdist.sh
new file mode 100755
index 000000000000..64040707c88c
--- /dev/null
+++ b/contrib/build-linux/sdist/make_sdist.sh
@@ -0,0 +1,76 @@
+#!/bin/bash
+
+set -e
+
+PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/../../.."
+CONTRIB="$PROJECT_ROOT/contrib"
+CONTRIB_SDIST="$CONTRIB/build-linux/sdist"
+DISTDIR="$PROJECT_ROOT/dist"
+BUILDDIR="$CONTRIB_SDIST/build"
+
+. "$CONTRIB"/build_tools_util.sh
+
+git -C "$PROJECT_ROOT" rev-parse 2>/dev/null || fail "Building outside a git clone is not supported."
+
+rm -rf "$BUILDDIR"
+mkdir -p "$BUILDDIR" "$DISTDIR"
+
+python3 --version || fail "python interpreter not found"
+
+break_legacy_easy_install
+
+rm -rf "$PROJECT_ROOT/packages/"
+if ([ "$OMIT_UNCLEAN_FILES" != 1 ]); then
+ "$CONTRIB"/make_packages.sh || fail "make_packages failed"
+fi
+
+info "preparing electrum-locale."
+(
+ "$CONTRIB/locale/build_cleanlocale.sh"
+ # By default, include both source (.po) and compiled (.mo) locale files in the source dist.
+ # Set option OMIT_UNCLEAN_FILES=1 to exclude the compiled locale files
+ # see https://askubuntu.com/a/144139 (also see MANIFEST.in)
+ if ([ "$OMIT_UNCLEAN_FILES" = 1 ]); then
+ rm -r "$PROJECT_ROOT/electrum/locale/locale"/*/LC_MESSAGES/electrum.mo
+ fi
+)
+
+(
+ cd "$PROJECT_ROOT"
+
+ 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
+ if ([ "$OMIT_UNCLEAN_FILES" = 1 ]); then
+ PY_DISTDIR="$BUILDDIR/dist1/_sourceonly" # The DISTDIR variable of this script is only used to find where the output is *finally* placed.
+ else
+ PY_DISTDIR="$BUILDDIR/dist1"
+ fi
+ # build initial tar.gz
+ python3 setup.py --quiet sdist --format=gztar --dist-dir="$PY_DISTDIR"
+
+ VERSION=$("$CONTRIB"/print_electrum_version.py)
+ if ([ "$OMIT_UNCLEAN_FILES" = 1 ]); then
+ FINAL_DISTNAME="Electrum-sourceonly-$VERSION.tar.gz"
+ else
+ FINAL_DISTNAME="Electrum-$VERSION.tar.gz"
+ fi
+ if ([ "$OMIT_UNCLEAN_FILES" = 1 ]); then
+ mv "$PY_DISTDIR/Electrum-$VERSION.tar.gz" "$PY_DISTDIR/../$FINAL_DISTNAME"
+ rmdir "$PY_DISTDIR"
+ fi
+
+ # the initial tar.gz is not reproducible, see https://github.com/pypa/setuptools/issues/2133
+ # so we untar, fix timestamps, and then re-tar
+ mkdir -p "$BUILDDIR/dist2"
+ cd "$BUILDDIR/dist2"
+ tar -xzf "$BUILDDIR/dist1/$FINAL_DISTNAME"
+ find -exec touch -h -d '2000-11-11T11:11:11+00:00' {} +
+ GZIP=-n tar --sort=name -czf "$FINAL_DISTNAME" "Electrum-$VERSION/"
+ mv "$FINAL_DISTNAME" "$DISTDIR/$FINAL_DISTNAME"
+)
+
+
+info "done."
+ls -la "$DISTDIR"
+sha256sum "$DISTDIR"/*
diff --git a/contrib/build-wine/.dockerignore b/contrib/build-wine/.dockerignore
new file mode 100644
index 000000000000..a3e70a0191e1
--- /dev/null
+++ b/contrib/build-wine/.dockerignore
@@ -0,0 +1,5 @@
+tmp/
+build/
+.cache/
+dist/
+signed/
diff --git a/contrib/build-wine/Dockerfile b/contrib/build-wine/Dockerfile
new file mode 100644
index 000000000000..23af6becf0e0
--- /dev/null
+++ b/contrib/build-wine/Dockerfile
@@ -0,0 +1,71 @@
+FROM debian:trixie@sha256:13f29b6806e531c3ff3b565bb6eed73f2132506c8c9d41bb996065ca20fb27f2
+
+# need ca-certificates before using snapshot packages
+RUN apt update -qq > /dev/null && apt install -qq --yes --no-install-recommends \
+ ca-certificates
+
+# pin the distro packages.
+COPY apt.sources.list /etc/apt/sources.list
+COPY apt.preferences /etc/apt/preferences.d/snapshot
+
+ENV LC_ALL=C.UTF-8 LANG=C.UTF-8
+ENV DEBIAN_FRONTEND=noninteractive
+
+RUN dpkg --add-architecture i386 && \
+ apt-get update -q && \
+ apt-get install -qy --allow-downgrades \
+ lsb-release \
+ wget \
+ gnupg2 \
+ dirmngr \
+ python3 \
+ git \
+ p7zip-full \
+ make \
+ cmake \
+ pkgconf \
+ mingw-w64 \
+ mingw-w64-tools \
+ autotools-dev \
+ autoconf \
+ autopoint \
+ libtool \
+ gettext \
+ sudo \
+ nsis \
+ && \
+ rm -rf /var/lib/apt/lists/* && \
+ apt-get autoremove -y && \
+ apt-get clean
+
+RUN DEBIAN_CODENAME=$(lsb_release --codename --short) && \
+ WINEVERSION="11.0.0.0~${DEBIAN_CODENAME}-1" && \
+ wget -nc https://dl.winehq.org/wine-builds/winehq.key && \
+ echo "d965d646defe94b3dfba6d5b4406900ac6c81065428bf9d9303ad7a72ee8d1b8 winehq.key" | sha256sum -c - && \
+ cat winehq.key | gpg --dearmor -o /etc/apt/keyrings/winehq.gpg && \
+ echo deb [signed-by=/etc/apt/keyrings/winehq.gpg] https://dl.winehq.org/wine-builds/debian/ ${DEBIAN_CODENAME} main >> /etc/apt/sources.list.d/winehq.list && \
+ rm winehq.key && \
+ apt-get update -q && \
+ apt-get install -qy --allow-downgrades \
+ wine-stable-amd64:amd64=${WINEVERSION} \
+ wine-stable-i386:i386=${WINEVERSION} \
+ wine-stable:amd64=${WINEVERSION} \
+ winehq-stable:amd64=${WINEVERSION} \
+ && \
+ rm -rf /var/lib/apt/lists/* && \
+ apt-get autoremove -y && \
+ apt-get clean
+
+# create new user to avoid using root; but with sudo access and no password for convenience.
+ARG UID=1000
+RUN useradd -u "$UID" -m -s /usr/bin/bash -d /home/user user
+RUN usermod -aG sudo user
+RUN echo "%sudo ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
+ENV HOME_DIR=/home/user
+ENV WORK_DIR="${HOME_DIR}/wspace"
+ENV PATH="${HOME_DIR}/.local/bin:${PATH}"
+WORKDIR ${WORK_DIR}
+RUN chown -R user ${WORK_DIR} /opt
+USER user
+
+RUN mkdir --parents "/opt/wine64/drive_c/electrum"
diff --git a/contrib/build-wine/README.md b/contrib/build-wine/README.md
index b63485e87f8e..1f3d43c65c74 100644
--- a/contrib/build-wine/README.md
+++ b/contrib/build-wine/README.md
@@ -1,38 +1,93 @@
-Windows Binary Builds
-=====================
+# Windows binaries
-These scripts can be used for cross-compilation of Windows Electrum executables from Linux/Wine.
-Produced binaries are deterministic so you should be able to generate binaries that match the official releases.
+✓ _These binaries should be reproducible, meaning you should be able to generate
+ binaries that match the official releases._
+- _Minimum supported target system (i.e. what end-users need): x86_64, Windows 10 (1809)_
-Usage:
+This assumes an Ubuntu (x86_64) host, but it should not be too hard to adapt to another
+similar system.
+1. Install Docker
-1. Install the following dependencies:
+ See [`contrib/docker_notes.md`](../docker_notes.md).
- - dirmngr
- - gpg
- - Wine (>= v2)
+ (worth reading even if you already have docker)
+ Note: older versions of Docker might not work well
+ (see [#6971](https://github.com/spesmilo/electrum/issues/6971)).
+ If having problems, try to upgrade to at least `docker 20.10`.
-For example:
+2. Build Windows binaries
+ ```
+ $ ./build.sh
+ ```
+ If you want reproducibility, try instead e.g.:
+ ```
+ $ ELECBUILD_COMMIT=HEAD ./build.sh
+ ```
-```
-$ sudo apt-get install wine-development dirmngr gnupg2
-$ sudo ln -sf /usr/bin/wine-development /usr/local/bin/wine
-$ wine --version
- wine-2.0 (Debian 2.0-3+b2)
-```
+3. The generated binaries are in `./contrib/build-wine/dist`.
+
+
+
+## Code Signing
+
+Electrum Windows builds are signed with a Microsoft Authenticode™ code signing
+certificate in addition to the GPG-based signatures.
+
+The advantage of using Authenticode is that Electrum users won't receive a
+Windows SmartScreen warning when starting it.
+
+The release signing procedure involves a signer (the holder of the
+certificate/key) and one or multiple trusted verifiers:
+
+
+| Signer | Verifier |
+|-----------------------------------------------------------|--------------------------------------|
+| Build .exe files using `make_win.sh` | |
+| Sign .exe with `./sign.sh` | |
+| Upload signed files to download server | |
+| | Build .exe files using `make_win.sh` |
+| | Compare files using `unsign.sh` |
+| | Sign .exe file using `gpg -b` |
+
+| Signer and verifiers: |
+|--------------------------------------------------------------------------------------------------|
+| Upload signatures to 'electrum-signatures' repo, as `$version/$filename.$builder.asc` |
-or
+
+## Verify Integrity of signed binary
+
+Every user can verify that the official binary was created from the source code in this
+repository. To do so, the Authenticode signature needs to be stripped since the signature
+is not reproducible.
+
+This procedure removes the differences between the signed and unsigned binary:
+
+1. Remove the signature from the signed binary using osslsigncode or signtool.
+2. Set the COFF image checksum for the signed binary to 0x0. This is necessary
+ because pyinstaller doesn't generate a checksum.
+3. Append null bytes to the _unsigned_ binary until the byte count is a multiple
+ of 8.
+
+The script `unsign.sh` performs these steps.
+
+## FAQ
+
+### How to investigate diff between binaries if reproducibility fails?
+`pyi-archive_viewer` is needed, for that run `$ pip install pyinstaller`.
+As a first pass overview, run:
```
-$ pacman -S wine gnupg
-$ wine --version
- wine-2.21
+pyi-archive_viewer -l electrum-*.exe1 > f1
+pyi-archive_viewer -l electrum-*.exe2 > f2
+diff f1 f2 > d
+cat d
+```
+Then investigate manually:
+```
+$ pyi-archive_viewer electrum-*.exe1
+? help
```
-
-2. Make sure `/opt` is writable by the current user.
-3. Run `build.sh`.
-4. The generated binaries are in `./dist`.
diff --git a/contrib/build-wine/README_windows.md b/contrib/build-wine/README_windows.md
new file mode 100644
index 000000000000..b6ac49ac55be
--- /dev/null
+++ b/contrib/build-wine/README_windows.md
@@ -0,0 +1,66 @@
+# Running Electrum from source on Windows (development version)
+
+## Prerequisites
+
+- [python3](https://www.python.org/)
+- [git](https://gitforwindows.org/)
+
+## Main steps
+
+### 1. Check out the code from GitHub:
+```
+> git clone https://github.com/spesmilo/electrum.git
+> cd electrum
+> git submodule update --init
+```
+
+Run install (this should install most dependencies):
+```
+> python3 -m pip install --user -e ".[gui,crypto]"
+```
+
+### 2. Install `libsecp256k1`
+
+[comment]: # (technically the dll should be put into site-packages/electrum_ecc/,
+but putting it into electrum/ also works because of the `os.add_dll_directory` call in
+electrum/__init__.py)
+
+[libsecp256k1](https://github.com/bitcoin-core/secp256k1) is a required dependency.
+This is a C library, which you need to compile yourself.
+Electrum needs a dll, named `libsecp256k1-0.dll` (or newer `libsecp256k1-*.dll`),
+placed into the inner `electrum/` folder.
+
+For Unix-like systems, the (`contrib/make_libsecp256k1.sh`) script does this for you,
+however it does not work on Windows.
+If you have access to a Linux machine (e.g. VM) or perhaps even using
+WSL (Windows Subsystem for Linux), you can cross-compile from there to Windows,
+and build this dll:
+```
+$ GCC_TRIPLET_HOST="x86_64-w64-mingw32" ./contrib/make_libsecp256k1.sh
+```
+
+Alternatively, MSYS2 and MinGW-w64 can be used directly on Windows, as follows.
+
+- download and install [MSYS2](https://www.msys2.org/)
+- run MSYS2
+- inside the MSYS2 shell:
+ ```
+ $ pacman -Syu
+ $ pacman -S --needed git base-devel mingw-w64-x86_64-toolchain mingw-w64-x86_64-autotools
+ $ export PATH="$PATH:/mingw64/bin"
+ ```
+ `cd` into the git clone, e.g. `C:\wspace\electrum` (auto-mounted at `/c/wspace/electrum`)
+ ```
+ $ cd /c/wspace/electrum
+ $ GCC_TRIPLET_HOST="x86_64-w64-mingw32" ./contrib/make_libsecp256k1.sh
+ ```
+
+(note: this is a bit cumbersome, see [issue #5976](https://github.com/spesmilo/electrum/issues/5976)
+for discussion)
+
+### 3. Run electrum:
+
+```
+> python3 ./run_electrum
+```
+
diff --git a/contrib/build-wine/apt.preferences b/contrib/build-wine/apt.preferences
new file mode 100644
index 000000000000..d861cd83d960
--- /dev/null
+++ b/contrib/build-wine/apt.preferences
@@ -0,0 +1,3 @@
+Package: *
+Pin: origin "snapshot.debian.org"
+Pin-Priority: 1001
diff --git a/contrib/build-wine/apt.sources.list b/contrib/build-wine/apt.sources.list
new file mode 100644
index 000000000000..dd5bd469f769
--- /dev/null
+++ b/contrib/build-wine/apt.sources.list
@@ -0,0 +1,2 @@
+deb https://snapshot.debian.org/archive/debian/20260227T144551Z/ trixie main
+deb-src https://snapshot.debian.org/archive/debian/20260227T144551Z/ trixie main
diff --git a/contrib/build-wine/build-electrum-git.sh b/contrib/build-wine/build-electrum-git.sh
index c849a672d813..10c1ac1c6e08 100755
--- a/contrib/build-wine/build-electrum-git.sh
+++ b/contrib/build-wine/build-electrum-git.sh
@@ -1,85 +1,122 @@
#!/bin/bash
NAME_ROOT=electrum
-PYTHON_VERSION=3.5.4
+PROJECT_ROOT="$WINEPREFIX/drive_c/electrum"
-# These settings probably don't need any change
-export WINEPREFIX=/opt/wine64
-export PYTHONDONTWRITEBYTECODE=1
-export PYTHONHASHSEED=22
-
-PYHOME=c:/python$PYTHON_VERSION
-PYTHON="wine $PYHOME/python.exe -OO -B"
+export PYTHONDONTWRITEBYTECODE=1 # don't create __pycache__/ folders with .pyc files
# Let's begin!
-cd `dirname $0`
set -e
-cd tmp
-
-for repo in electrum electrum-locale electrum-icons; do
- if [ -d $repo ]; then
- cd $repo
- git pull
- git checkout master
- cd ..
- else
- URL=https://github.com/spesmilo/$repo.git
- git clone -b master $URL $repo
- fi
-done
-
-pushd electrum-locale
-for i in ./locale/*; do
- dir=$i/LC_MESSAGES
- mkdir -p $dir
- msgfmt --output-file=$dir/electrum.mo $i/electrum.po || true
-done
-popd
+. "$CONTRIB"/build_tools_util.sh
-pushd electrum
-if [ ! -z "$1" ]; then
- git checkout $1
-fi
+pushd "$PROJECT_ROOT"
-VERSION=`git describe --tags`
-echo "Last commit: $VERSION"
-find -exec touch -d '2000-11-11T11:11:11+00:00' {} +
-popd
+VERSION=$(git describe --tags --dirty --always)
+info "Last commit: $VERSION"
-rm -rf $WINEPREFIX/drive_c/electrum
-cp -r electrum $WINEPREFIX/drive_c/electrum
-cp electrum/LICENCE .
-cp -r electrum-locale/locale $WINEPREFIX/drive_c/electrum/lib/
-cp electrum-icons/icons_rc.py $WINEPREFIX/drive_c/electrum/gui/qt/
+info "preparing electrum-locale."
+(
+ "$CONTRIB/locale/build_cleanlocale.sh"
+ # we want the binary to have only compiled (.mo) locale files; not source (.po) files
+ rm -r "$PROJECT_ROOT/electrum/locale/locale"/*/electrum.po
+)
+
+find -exec touch -h -d '2000-11-11T11:11:11+00:00' {} +
+popd
-# Install frozen dependencies
-$PYTHON -m pip install -r ../../requirements.txt
-pushd $WINEPREFIX/drive_c/electrum
-$PYTHON setup.py install
+# opt out of compiling C extensions
+export AIOHTTP_NO_EXTENSIONS=1
+export YARL_NO_EXTENSIONS=1
+export MULTIDICT_NO_EXTENSIONS=1
+export FROZENLIST_NO_EXTENSIONS=1
+export PROPCACHE_NO_EXTENSIONS=1
+export ELECTRUM_ECC_DONT_COMPILE=1
+
+info "Installing requirements..."
+$WINE_PYTHON -m pip install --no-build-isolation --no-dependencies --no-binary :all: --no-warn-script-location \
+ --cache-dir "$WINE_PIP_CACHE_DIR" -r "$CONTRIB"/deterministic-build/requirements.txt
+info "Installing dependencies specific to binaries..."
+# TODO tighten "--no-binary :all:" (but we don't have a C compiler...)
+$WINE_PYTHON -m pip install --no-build-isolation --no-dependencies --no-warn-script-location \
+ --no-binary :all: --only-binary cffi,cryptography,PyQt6,PyQt6-Qt6,PyQt6-sip \
+ --cache-dir "$WINE_PIP_CACHE_DIR" -r "$CONTRIB"/deterministic-build/requirements-binaries.txt
+info "Installing hardware wallet requirements..."
+$WINE_PYTHON -m pip install --no-build-isolation --no-dependencies --no-warn-script-location \
+ --no-binary :all: --only-binary cffi,cryptography,hidapi \
+ --cache-dir "$WINE_PIP_CACHE_DIR" -r "$CONTRIB"/deterministic-build/requirements-hw.txt
+
+pushd "$PROJECT_ROOT"
+# see https://github.com/pypa/pip/issues/2195 -- pip makes a copy of the entire directory
+info "Pip installing Electrum. This might take a long time if the project folder is large."
+$WINE_PYTHON -m pip install --no-build-isolation --no-dependencies --no-warn-script-location .
+# pyinstaller needs to be able to "import electrum_ecc", for which we need libsecp256k1:
+# (or could try "pip install -e" instead)
+cp electrum/libsecp256k1-*.dll "$WINEPREFIX/drive_c/python3/Lib/site-packages/electrum_ecc/"
popd
-cd ..
rm -rf dist/
# build standalone and portable versions
-wine "C:/python$PYTHON_VERSION/scripts/pyinstaller.exe" --noconfirm --ascii --name $NAME_ROOT-$VERSION -w deterministic.spec
+info "Running pyinstaller..."
+ELECTRUM_CMDLINE_NAME="$NAME_ROOT-$VERSION" wine "$WINE_PYHOME/scripts/pyinstaller.exe" --noconfirm --clean pyinstaller.spec
# set timestamps in dist, in order to make the installer reproducible
pushd dist
-find -exec touch -d '2000-11-11T11:11:11+00:00' {} +
+find -exec touch -h -d '2000-11-11T11:11:11+00:00' {} +
popd
-# build NSIS installer
-# $VERSION could be passed to the electrum.nsi script, but this would require some rewriting in the script iself.
-wine "$WINEPREFIX/drive_c/Program Files (x86)/NSIS/makensis.exe" /DPRODUCT_VERSION=$VERSION electrum.nsi
+info "building NSIS installer"
+# $VERSION could be passed to the electrum.nsi script, but this would require some rewriting in the script itself.
+makensis -DPRODUCT_VERSION=$VERSION electrum.nsi
cd dist
mv electrum-setup.exe $NAME_ROOT-$VERSION-setup.exe
cd ..
-echo "Done."
-md5sum dist/electrum*exe
+info "Padding binaries to 8-byte boundaries, and fixing COFF image checksum in PE header"
+# note: 8-byte boundary padding is what osslsigncode uses:
+# https://github.com/mtrojnar/osslsigncode/blob/6c8ec4427a0f27c145973450def818e35d4436f6/osslsigncode.c#L3047
+(
+ cd dist
+ for binary_file in ./*.exe; do
+ info ">> fixing $binary_file..."
+ # code based on https://github.com/erocarrera/pefile/blob/bbf28920a71248ed5c656c81e119779c131d9bd4/pefile.py#L5877
+ python3 <> 32)
+ if checksum > 2 ** 32:
+ checksum = (checksum & 0xffffffff) + (checksum >> 32)
+
+checksum = (checksum & 0xffff) + (checksum >> 16)
+checksum = (checksum) + (checksum >> 16)
+checksum = checksum & 0xffff
+checksum += len(binary)
+
+# Set the checksum
+binary[checksum_offset : checksum_offset + 4] = int.to_bytes(checksum, byteorder="little", length=4)
+
+with open(pe_file, "wb") as f:
+ f.write(binary)
+EOF
+ done
+)
+
+sha256sum dist/electrum*.exe
diff --git a/contrib/build-wine/build.sh b/contrib/build-wine/build.sh
index 5f00e824bea6..c10e8751ad3c 100755
--- a/contrib/build-wine/build.sh
+++ b/contrib/build-wine/build.sh
@@ -1,27 +1,77 @@
#!/bin/bash
-# Lucky number
-export PYTHONHASHSEED=22
+#
+# env vars:
+# - ELECBUILD_NOCACHE: if set, forces rebuild of docker image
+# - ELECBUILD_COMMIT: if set, do a fresh clone and git checkout
-if [ ! -z "$1" ]; then
- to_build="$1"
+set -e
+
+PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/../.."
+PROJECT_ROOT_OR_FRESHCLONE_ROOT="$PROJECT_ROOT"
+CONTRIB="$PROJECT_ROOT/contrib"
+CONTRIB_WINE="$CONTRIB/build-wine"
+BUILD_UID=$(/usr/bin/stat -c %u "$PROJECT_ROOT")
+
+. "$CONTRIB"/build_tools_util.sh
+
+info "Clearing $CONTRIB_WINE/dist..."
+rm -rf "$CONTRIB_WINE"/dist/*
+
+
+DOCKER_BUILD_FLAGS=""
+if [ ! -z "$ELECBUILD_NOCACHE" ] ; then
+ info "ELECBUILD_NOCACHE is set. forcing rebuild of docker image."
+ DOCKER_BUILD_FLAGS="--pull --no-cache"
fi
-here=$(dirname "$0")
+if [ -z "$ELECBUILD_COMMIT" ] ; then # local dev build
+ DOCKER_BUILD_FLAGS="$DOCKER_BUILD_FLAGS --build-arg UID=$BUILD_UID"
+fi
-echo "Clearing $here/build and $here/dist..."
-rm $here/build/* -rf
-rm $here/dist/* -rf
+info "building docker image."
+docker build \
+ $DOCKER_BUILD_FLAGS \
+ -t electrum-wine-builder-img \
+ "$CONTRIB_WINE"
-$here/prepare-wine.sh && \
-$here/prepare-pyinstaller.sh && \
-$here/prepare-hw.sh || exit 1
+# maybe do fresh clone
+if [ ! -z "$ELECBUILD_COMMIT" ] ; then
+ info "ELECBUILD_COMMIT=$ELECBUILD_COMMIT. doing fresh clone and git checkout."
+ FRESH_CLONE="/tmp/electrum_build/windows/fresh_clone/electrum"
+ rm -rf "$FRESH_CLONE" 2>/dev/null || ( info "we need sudo to rm prev FRESH_CLONE." && sudo rm -rf "$FRESH_CLONE" )
+ umask 0022
+ git clone "$PROJECT_ROOT" "$FRESH_CLONE"
+ cd "$FRESH_CLONE"
+ git checkout "$ELECBUILD_COMMIT"
+ PROJECT_ROOT_OR_FRESHCLONE_ROOT="$FRESH_CLONE"
+else
+ info "not doing fresh clone."
+fi
-echo "Resetting modification time in C:\Python..."
-# (Because of some bugs in pyinstaller)
-pushd /opt/wine64/drive_c/python*
-find -exec touch -d '2000-11-11T11:11:11+00:00' {} +
-popd
-ls -l /opt/wine64/drive_c/python*
+DOCKER_RUN_FLAGS=""
+if sh -c ": >/dev/tty" >/dev/null 2>/dev/null; then
+ info "/dev/tty is available and usable"
+ DOCKER_RUN_FLAGS="-it"
+fi
-$here/build-electrum-git.sh $to_build && \
-echo "Done."
+info "building binary..."
+# check uid and maybe chown. see #8261
+if [ ! -z "$ELECBUILD_COMMIT" ] ; then # fresh clone (reproducible build)
+ if [ $(id -u) != "1000" ] || [ $(id -g) != "1000" ] ; then
+ info "need to chown -R FRESH_CLONE dir. prompting for sudo."
+ sudo chown -R 1000:1000 "$FRESH_CLONE"
+ fi
+fi
+docker run $DOCKER_RUN_FLAGS \
+ --name electrum-wine-builder-cont \
+ -v "$PROJECT_ROOT_OR_FRESHCLONE_ROOT":/opt/wine64/drive_c/electrum \
+ --rm \
+ --workdir /opt/wine64/drive_c/electrum/contrib/build-wine \
+ electrum-wine-builder-img \
+ ./make_win.sh
+
+# make sure resulting binary location is independent of fresh_clone
+if [ ! -z "$ELECBUILD_COMMIT" ] ; then
+ mkdir --parents "$PROJECT_ROOT/contrib/build-wine/dist/"
+ cp -f "$FRESH_CLONE/contrib/build-wine/dist"/*.exe "$PROJECT_ROOT/contrib/build-wine/dist/"
+fi
diff --git a/contrib/build-wine/deterministic.spec b/contrib/build-wine/deterministic.spec
deleted file mode 100644
index 0bda53dc074c..000000000000
--- a/contrib/build-wine/deterministic.spec
+++ /dev/null
@@ -1,123 +0,0 @@
-# -*- mode: python -*-
-
-from PyInstaller.utils.hooks import collect_data_files, collect_submodules
-
-import sys
-for i, x in enumerate(sys.argv):
- if x == '--name':
- cmdline_name = sys.argv[i+1]
- break
-else:
- raise BaseException('no name')
-
-
-home = 'C:\\electrum\\'
-
-# see https://github.com/pyinstaller/pyinstaller/issues/2005
-hiddenimports = []
-hiddenimports += collect_submodules('trezorlib')
-hiddenimports += collect_submodules('btchip')
-hiddenimports += collect_submodules('keepkeylib')
-
-datas = [
- (home+'lib/currencies.json', 'electrum'),
- (home+'lib/servers.json', 'electrum'),
- (home+'lib/checkpoints.json', 'electrum'),
- (home+'lib/servers_testnet.json', 'electrum'),
- (home+'lib/wordlist/english.txt', 'electrum/wordlist'),
- (home+'lib/locale', 'electrum/locale'),
- (home+'plugins', 'electrum_plugins'),
-]
-datas += collect_data_files('trezorlib')
-datas += collect_data_files('btchip')
-datas += collect_data_files('keepkeylib')
-
-# We don't put these files in to actually include them in the script but to make the Analysis method scan them for imports
-a = Analysis([home+'electrum',
- home+'gui/qt/main_window.py',
- home+'gui/text.py',
- home+'lib/util.py',
- home+'lib/wallet.py',
- home+'lib/simple_config.py',
- home+'lib/bitcoin.py',
- home+'lib/dnssec.py',
- home+'lib/commands.py',
- home+'plugins/cosigner_pool/qt.py',
- home+'plugins/email_requests/qt.py',
- home+'plugins/trezor/client.py',
- home+'plugins/trezor/qt.py',
- home+'plugins/keepkey/qt.py',
- home+'plugins/ledger/qt.py',
- #home+'packages/requests/utils.py'
- ],
- datas=datas,
- #pathex=[home+'lib', home+'gui', home+'plugins'],
- hiddenimports=hiddenimports,
- hookspath=[])
-
-
-# http://stackoverflow.com/questions/19055089/pyinstaller-onefile-warning-pyconfig-h-when-importing-scipy-or-scipy-signal
-for d in a.datas:
- if 'pyconfig' in d[0]:
- a.datas.remove(d)
- break
-
-# hotfix for #3171 (pre-Win10 binaries)
-a.binaries = [x for x in a.binaries if not x[1].lower().startswith(r'c:\windows')]
-
-pyz = PYZ(a.pure)
-
-
-#####
-# "standalone" exe with all dependencies packed into it
-
-exe_standalone = EXE(
- pyz,
- a.scripts,
- a.binaries,
- a.datas,
- name=os.path.join('build\\pyi.win32\\electrum', cmdline_name + ".exe"),
- debug=False,
- strip=None,
- upx=False,
- icon=home+'icons/electrum.ico',
- console=False)
- # console=True makes an annoying black box pop up, but it does make Electrum output command line commands, with this turned off no output will be given but commands can still be used
-
-exe_portable = EXE(
- pyz,
- a.scripts,
- a.binaries,
- a.datas + [ ('is_portable', 'README.md', 'DATA' ) ],
- name=os.path.join('build\\pyi.win32\\electrum', cmdline_name + "-portable.exe"),
- debug=False,
- strip=None,
- upx=False,
- icon=home+'icons/electrum.ico',
- console=False)
-
-#####
-# exe and separate files that NSIS uses to build installer "setup" exe
-
-exe_dependent = EXE(
- pyz,
- a.scripts,
- exclude_binaries=True,
- name=os.path.join('build\\pyi.win32\\electrum', cmdline_name),
- debug=False,
- strip=None,
- upx=False,
- icon=home+'icons/electrum.ico',
- console=False)
-
-coll = COLLECT(
- exe_dependent,
- a.binaries,
- a.zipfiles,
- a.datas,
- strip=None,
- upx=True,
- debug=False,
- icon=home+'icons/electrum.ico',
- console=False,
- name=os.path.join('dist', 'electrum'))
diff --git a/contrib/build-wine/electrum.nsi b/contrib/build-wine/electrum.nsi
index 0a30033257ea..23111d5e4306 100644
--- a/contrib/build-wine/electrum.nsi
+++ b/contrib/build-wine/electrum.nsi
@@ -1,8 +1,8 @@
;--------------------------------
;Include Modern UI
- !include "TextFunc.nsh" ;Needed for the $GetSize fuction. I know, doesn't sound logical, it isn't.
+ !include "TextFunc.nsh" ;Needed for the $GetSize function. I know, doesn't sound logical, it isn't.
!include "MUI2.nsh"
-
+
;--------------------------------
;Variables
@@ -19,7 +19,7 @@
OutFile "dist/electrum-setup.exe"
;Default installation folder
- InstallDir "$PROGRAMFILES\${PRODUCT_NAME}"
+ InstallDir "$PROGRAMFILES64\${PRODUCT_NAME}"
;Get installation folder from registry if available
InstallDirRegKey HKCU "Software\${PRODUCT_NAME}" ""
@@ -29,41 +29,41 @@
;Specifies whether or not the installer will perform a CRC on itself before allowing an install
CRCCheck on
-
+
;Sets whether or not the details of the install are shown. Can be 'hide' (the default) to hide the details by default, allowing the user to view them, or 'show' to show them by default, or 'nevershow', to prevent the user from ever seeing them.
ShowInstDetails show
-
+
;Sets whether or not the details of the uninstall are shown. Can be 'hide' (the default) to hide the details by default, allowing the user to view them, or 'show' to show them by default, or 'nevershow', to prevent the user from ever seeing them.
ShowUninstDetails show
-
+
;Sets the colors to use for the install info screen (the default is 00FF00 000000. Use the form RRGGBB (in hexadecimal, as in HTML, only minus the leading '#', since # can be used for comments). Note that if "/windows" is specified as the only parameter, the default windows colors will be used.
InstallColors /windows
-
+
;This command sets the compression algorithm used to compress files/data in the installer. (http://nsis.sourceforge.net/Reference/SetCompressor)
SetCompressor /SOLID lzma
-
+
;Sets the dictionary size in megabytes (MB) used by the LZMA compressor (default is 8 MB).
SetCompressorDictSize 64
-
+
;Sets the text that is shown (by default it is 'Nullsoft Install System vX.XX') in the bottom of the install window. Setting this to an empty string ("") uses the default; to set the string to blank, use " " (a space).
- BrandingText "${PRODUCT_NAME} Installer v${PRODUCT_VERSION}"
-
+ BrandingText "${PRODUCT_NAME} Installer v${PRODUCT_VERSION}"
+
;Sets what the titlebars of the installer will display. By default, it is 'Name Setup', where Name is specified with the Name command. You can, however, override it with 'MyApp Installer' or whatever. If you specify an empty string (""), the default will be used (you can however specify " " to achieve a blank string)
Caption "${PRODUCT_NAME}"
;Adds the Product Version on top of the Version Tab in the Properties of the file.
VIProductVersion 1.0.0.0
-
+
;VIAddVersionKey - Adds a field in the Version Tab of the File Properties. This can either be a field provided by the system or a user defined field.
VIAddVersionKey ProductName "${PRODUCT_NAME} Installer"
VIAddVersionKey Comments "The installer for ${PRODUCT_NAME}"
VIAddVersionKey CompanyName "${PRODUCT_NAME}"
- VIAddVersionKey LegalCopyright "2013-2016 ${PRODUCT_PUBLISHER}"
+ VIAddVersionKey LegalCopyright "2013-2018 ${PRODUCT_PUBLISHER}"
VIAddVersionKey FileDescription "${PRODUCT_NAME} Installer"
VIAddVersionKey FileVersion ${PRODUCT_VERSION}
VIAddVersionKey ProductVersion ${PRODUCT_VERSION}
VIAddVersionKey InternalName "${PRODUCT_NAME} Installer"
- VIAddVersionKey LegalTrademarks "${PRODUCT_NAME} is a trademark of ${PRODUCT_PUBLISHER}"
+ VIAddVersionKey LegalTrademarks "${PRODUCT_NAME} is a trademark of ${PRODUCT_PUBLISHER}"
VIAddVersionKey OriginalFilename "${PRODUCT_NAME}.exe"
;--------------------------------
@@ -71,9 +71,9 @@
!define MUI_ABORTWARNING
!define MUI_ABORTWARNING_TEXT "Are you sure you wish to abort the installation of ${PRODUCT_NAME}?"
-
- !define MUI_ICON "tmp\electrum\icons\electrum.ico"
-
+
+ !define MUI_ICON "..\..\electrum\gui\icons\electrum.ico"
+
;--------------------------------
;Pages
@@ -87,6 +87,55 @@
!insertmacro MUI_LANGUAGE "English"
+;--------------------------------
+;Functions
+
+!macro CreateEnsureNotRunning prefix operation
+
+Function ${prefix}EnsureNotRunning
+ ; pop the directory to check from the stack into $R0
+ Pop $R0
+ ; if the dir at $R0 doesn't exist, jump to nodir
+ IfFileExists "$R0" 0 nodir
+ ; Find all .exe files in the directory, $1 is the handle, $2 is the filename
+ FindFirst $1 $2 "$R0\*.exe"
+ IfErrors noexe 0
+
+ checkloop:
+ ; Skip checking the uninstaller if we are the uninstaller to avoid locking the uninstaller itself
+ !if "${prefix}" == "un."
+ StrCmp $2 "Uninstall.exe" skipfile 0
+ !endif
+
+ ; Check if we can append to the .exe file. If we can't that means it is still running.
+ retryopen:
+ FileOpen $0 "$R0\$2" a
+ IfErrors 0 closeexe
+ MessageBox MB_RETRYCANCEL "Can not ${operation} because $2 is still running. Close it and retry." /SD IDCANCEL IDRETRY retryopen
+ FindClose $1
+ Abort
+ closeexe:
+ FileClose $0
+
+ skipfile:
+ ; Find next .exe file
+ FindNext $1 $2
+ IfErrors done 0
+ Goto checkloop
+
+ done:
+ FindClose $1
+
+ noexe:
+ nodir:
+FunctionEnd
+
+!macroend
+
+; The function has to be created twice, once for the installer and once for the uninstaller
+!insertmacro CreateEnsureNotRunning "" "install"
+!insertmacro CreateEnsureNotRunning "un." "uninstall"
+
;--------------------------------
;Installer Sections
@@ -99,6 +148,14 @@ Function .onInit
SetErrorLevel 740 ;ERROR_ELEVATION_REQUIRED
Quit
${EndIf}
+
+ ; Check if already installed and ensure the process is not running if it is
+ ReadRegStr $R0 HKCU "Software\${PRODUCT_NAME}" ""
+ IfErrors noinstdir 0
+ Push $R0
+ Call EnsureNotRunning
+ noinstdir:
+ ClearErrors
FunctionEnd
Section
@@ -108,10 +165,10 @@ Section
RMDir /r "$INSTDIR\*.*"
Delete "$DESKTOP\${PRODUCT_NAME}.lnk"
Delete "$SMPROGRAMS\${PRODUCT_NAME}\*.*"
-
+
;Files to pack into the installer
File /r "dist\electrum\*.*"
- File "..\..\icons\electrum.ico"
+ File "..\..\electrum\gui\icons\electrum.ico"
;Store installation folder
WriteRegStr HKCU "Software\${PRODUCT_NAME}" "" $INSTDIR
@@ -132,13 +189,25 @@ Section
CreateShortCut "$SMPROGRAMS\${PRODUCT_NAME}\${PRODUCT_NAME} Testnet.lnk" "$INSTDIR\electrum-${PRODUCT_VERSION}.exe" "--testnet" "$INSTDIR\electrum-${PRODUCT_VERSION}.exe" 0
- ;Links bitcoin: URI's to Electrum
+ ;Links bitcoin:, lightning: and lnurl LUD-17 URIs to Electrum
WriteRegStr HKCU "Software\Classes\bitcoin" "" "URL:bitcoin Protocol"
WriteRegStr HKCU "Software\Classes\bitcoin" "URL Protocol" ""
WriteRegStr HKCU "Software\Classes\bitcoin" "DefaultIcon" "$\"$INSTDIR\electrum.ico, 0$\""
WriteRegStr HKCU "Software\Classes\bitcoin\shell\open\command" "" "$\"$INSTDIR\electrum-${PRODUCT_VERSION}.exe$\" $\"%1$\""
-
- ;Adds an uninstaller possibilty to Windows Uninstall or change a program section
+ WriteRegStr HKCU "Software\Classes\lightning" "" "URL:lightning Protocol"
+ WriteRegStr HKCU "Software\Classes\lightning" "URL Protocol" ""
+ WriteRegStr HKCU "Software\Classes\lightning" "DefaultIcon" "$\"$INSTDIR\electrum.ico, 0$\""
+ WriteRegStr HKCU "Software\Classes\lightning\shell\open\command" "" "$\"$INSTDIR\electrum-${PRODUCT_VERSION}.exe$\" $\"%1$\""
+ WriteRegStr HKCU "Software\Classes\lnurlp" "" "URL:lnurlp Protocol"
+ WriteRegStr HKCU "Software\Classes\lnurlp" "URL Protocol" ""
+ WriteRegStr HKCU "Software\Classes\lnurlp" "DefaultIcon" "$\"$INSTDIR\electrum.ico, 0$\""
+ WriteRegStr HKCU "Software\Classes\lnurlp\shell\open\command" "" "$\"$INSTDIR\electrum-${PRODUCT_VERSION}.exe$\" $\"%1$\""
+ WriteRegStr HKCU "Software\Classes\lnurlw" "" "URL:lnurlw Protocol"
+ WriteRegStr HKCU "Software\Classes\lnurlw" "URL Protocol" ""
+ WriteRegStr HKCU "Software\Classes\lnurlw" "DefaultIcon" "$\"$INSTDIR\electrum.ico, 0$\""
+ WriteRegStr HKCU "Software\Classes\lnurlw\shell\open\command" "" "$\"$INSTDIR\electrum-${PRODUCT_VERSION}.exe$\" $\"%1$\""
+
+ ;Adds an uninstaller possibility to Windows Uninstall or change a program section
WriteRegStr HKCU "${PRODUCT_UNINST_KEY}" "DisplayName" "$(^Name)"
WriteRegStr HKCU "${PRODUCT_UNINST_KEY}" "UninstallString" "$INSTDIR\Uninstall.exe"
WriteRegStr HKCU "${PRODUCT_UNINST_KEY}" "DisplayVersion" "${PRODUCT_VERSION}"
@@ -166,8 +235,17 @@ Section "Uninstall"
Delete "$DESKTOP\${PRODUCT_NAME}.lnk"
Delete "$SMPROGRAMS\${PRODUCT_NAME}\*.*"
RMDir "$SMPROGRAMS\${PRODUCT_NAME}"
-
+
DeleteRegKey HKCU "Software\Classes\bitcoin"
+ DeleteRegKey HKCU "Software\Classes\lightning"
+ DeleteRegKey HKCU "Software\Classes\lnurlp"
+ DeleteRegKey HKCU "Software\Classes\lnurlw"
DeleteRegKey HKCU "Software\${PRODUCT_NAME}"
DeleteRegKey HKCU "${PRODUCT_UNINST_KEY}"
SectionEnd
+
+Function UN.onInit
+ ; Ensure the process is not running in the uninstallation directory
+ Push $INSTDIR
+ Call un.EnsureNotRunning
+FunctionEnd
diff --git a/contrib/build-wine/gpg_keys/7ED10B6531D7C8E1BC296021FC624643487034E5.asc b/contrib/build-wine/gpg_keys/7ED10B6531D7C8E1BC296021FC624643487034E5.asc
new file mode 100644
index 000000000000..a87dbe1803d0
--- /dev/null
+++ b/contrib/build-wine/gpg_keys/7ED10B6531D7C8E1BC296021FC624643487034E5.asc
@@ -0,0 +1,108 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Comment: User-ID: Steve Dower (Python Release Signing)
+Comment: Created: 2015-04-06 02:32
+Comment: Type: 4096-bit RSA
+Comment: Usage: Signing, Encryption, Certifying User-IDs
+Comment: Fingerprint: 7ED10B6531D7C8E1BC296021FC624643487034E5
+
+
+mQINBFUh1AUBEACdUPt6PwJVO23zGZqgtgBeA9JsO22dk3CMzrwPJdUmMd6mcRWa
+vl4BoAba66fuC17GvOgGXimKI+iaw5Vt9QI3uSjUjFSfc24J8T7NB/yAr/0zEcex
+raHD2dxT/JpE/iY0yWHxRlitvwGSw1Qlq3NnY8tDI1DJEJD+gBuCktvVvu1FfQTw
+6bd+aEq0c4sWJHAOnKLuLH0pNFOznnynAFGPGBBsm/YwYc5BP2JVvka775LUjA+W
+1h2Sgg3FAUPIm64pc4Pq6mUo6Tulw72xsWMpCL1/5atXNPXT6rJUOB8euTcNMr4l
+1O6GKSsiLeLAuvq4bmhOKtLzjWzXnY1gDVoOfdgpD6o4ZHk4xiVsdVE8hCa/ylz8
+1ZwRW2gGo2jP8t3hKciR2i+Qs+6lPNZpeFIxa6Uo9ER1IBgCHHapIR/UdcOFyoS0
+MNn7Ui7DLQNM4gI/G17eG9tfvjW2dl4SgFSYWMq/OtXnPDUBGqFUWsn8adOL2PFL
+B7kM5ZRTPc5SnY9hoSGa5E20rJZIXcpy1aygRz/xUjoKwNzAySSEyyIorUxZ8KaH
+EEBQSsqwe04MXIENqnDozH0/cvP4JXEDSl8EkzMSCWSoavQSIYD5pQppyFQpGHqa
+5CuOA25Ja+sgp2xqahtr3fEqZUknPQSoYlnJbaHnzsGSlRAVWMsklsZibQARAQAB
+tEBTdGV2ZSBEb3dlciAoUHl0aG9uIFJlbGVhc2UgU2lnbmluZykgPHN0ZXZlLmRv
+d2VyQG1pY3Jvc29mdC5jb20+iQEcBBABCAAGBQJYsBphAAoJEEhSKohZ29goZggI
+ALKlgyoecD5v3ulh1eoctRqtCOxkAoENEfPt3l5x6N8Wq89yHzf10T1rVioEXOHh
+Di1m37DDoQmRJD0sOYQymq10xDGRYAJjyOf3X0pvRkZ+F7T0U4dSV3DasLIHcN26
+kRwv1yCYsf0QvhgT6EJZKyUNHtV9qrb9u3A1Zp6epC/EyT8zMZj+21GzTUrnbnug
+3Ak9p7+APCZS4Ahh9ZHFuD38MZ7+OwrUd6ot+6cbb1nnQLSAGQOHSp6EP6ktrnsK
+zts0L+tzHurxtJgUkR01imJuSFfYpLoZa/L7qXNyEpEUTC/SWzRWD9y2QkM7DLzX
+caReVAyJr9rix1lDQbEFIquJAhwEEAEIAAYFAlW2TwMACgkQKeBHm5nIo5fahg/+
+IQSSE/yH8Cf82PYI7IGqDVNwRw2o7dq8iscB+fhFHfFFhXANwUUFpzPeDMrMrdmq
+Bke7Vg1D3bIFocXYOiNwf2J7f4mBO6OL0VAvDX02Vyh/C2ZSc15uZyU6CWFQMCG8
+JOSmgQFs3kMHkL4qtut1Y5reoYesmteIe06UVyRw8yT1R1BkxP2whZ97qwsvUUE9
+cVD08wCvH486efw7EswIzYGa1KcZXji0MvjXfksVtkEQQbxMMI7SVXo0345ZReww
+buioGL5gvvAPObgU43skORanFHFxiHEKmqgHBHXK/LKqaFUFMKcb4iFTNs2XKrhE
+XsEi5EMI1AFsJzjcXRqT50Wi2cZhXeRc70uF6gzqrdWvowa2oOPiO6zGDiTqZCW1
+AArk/QBzGtPjVh+nKEdHwnvpK9913UAkAN682h8QkoVPYXOvIKDYZRBr5EfpUyQt
+y2r9MYewz0YN4zlGP1PFS9FxncdSZiZJqQVif0CkOp1tdSxLynHcujQgATZNtgcu
+X9JwUwPp60MurgOcIZiW3nZw/z/5vzBBadSa9/TIFSJAFNBlqeKdIGQuik0UH+Cz
+RRtSFb38F7jMPwr0QUSktuntQ0HWuvNqj4N8DFm45/n5rN190eRotrVDXZmjGein
+qWPITuICslGIKAp+Q6y3t7JA71MIbeu/ZY6ZcftOka6JAhwEEAEIAAYFAlZRWicA
+CgkQxiNM8COVzQq5bRAAktnXceO3GCivMt9yR1Qr0Ov4A4Q+CJSIL45efLFmS30k
+cbkHHtaq+0FZNh2ZaMartC16MUja4a2OUejg53VBhaSVkQrVk/6M/HA6/o6CvIhb
+FW/5C+nRWBd5gfvwsWvjrtC3cKZco4wg+yYclkDbSH+2EPDZOKIHpBy46YTz9WQ1
+8SJ51WVkNUNiZqRBA6Ny5GFoyd6EpWZYEPelmzNemv3zOrQdVzLV24/mLejcLL2t
+KmI6ngX4XViXUCRUU3MH8/V+V2YTQGcTM/6HGaHpN0LTqknf6zEto9q9FiRTaiU2
+kzExhBq8Qf+cVqwm+1kMt0FGOgpT47VBWMeUWq62gQ3h5NfAs4DfriLgNURlTC1d
+JYAEquFhB/8oBQD1h/d9CjQyk88iib2pJInRBDsK2FcfQBap9iaeBFYoBWTzMQJx
+g+RuWK1wIm2n0oqa5urBYZtRHE5RIdDP8ZLogrBOFkfXGJxlRBQD1Gab77qohdp0
+SnErGw4Ne3gJH/SNhK+zzHkHERIrRZCR95zdYkKfZ2jyOPzSuABVRigEQVQPCDn0
+hbv3cblTCeJYwG2mfRdmfyqSMALKIgXe9yvJ2kl8QgaVOsJjNfQzIKeoHFPIm5Uw
+3YB6jgDFc5uzEaH7WSz74A7KhGYjC7huw2TugosHbWxphJKddwxfK1WujYaAeJyJ
+AhwEEAEIAAYFAlf2sPIACgkQfb+tds3soNuXEQ//XkWYHmJsKyeDZC8MFU+/vsVq
+dhnFs6UXZkvf7MoNFkuMDL+zgVoMpFHftTdyBqNAoEnndakk212jK8YWF8g4kQXI
+a9uMRqJLM4mqCl9yco/twJ9z9EMA+JLSXYK0ZbTkLdutSDZEDKgpHbmekx2C1OsW
+lRLs9PahF5PAZQs0N+m+LJBnw6bEHOSTv4OE5uVUf9nvdes3OARvkGSEGURNmUaF
+chxWtZ/SF1q9Jfj0K/xgs9Gt855oueveRXLIGpjiEVoKH/drsgyKFMJVrpZDDgS4
+GVXG8bq3GTFiMAs7BPPd9bjI+jgvqttgItZcYsW/IQK1BIoG6Fere4cPvu+IshCc
+km9T8nOK98tZuov8hLbND9mW2d7LChJI1r/HbzbKIl0k6OigdFMrJlun2zmtDxT9
+Tp3uxOYSaW2YggcpNUjI28tv6AwoA8okVY93LWjO5kdZGkbliRnf/eJy7NJYn0LO
+ogsvMUJClRAGnZTHLEr32Whq0MImlXa43kr6oPJT5dwXXyw5ELstEQztczCd1PYB
+kbQHUpD5j3PwgNVOinCnbd4pc/qVtYSqpg2g6TJi1XiJ1638jhn2k+i8wop/dyet
+iN8lGR76twYGex9AavEAUpVR9r6qfpp4KBibEhdvL6o2O03RQu17GcRzXSAYzmUi
+5U5jZ3dBz5MYUjgUZM+JAhwEEAEKAAYFAllTh9gACgkQXLNh5VL7DRAk7Q//X8eU
+hwEvl/d9Sv2kBNCZFjAW3QmZp2L/sxhScJZXrOFzKUdmjap9Xlul1qr6/Wif7YLK
+bOdNUI7KziEBn+9SEd90XauoVkzU2F0Jn9ILGQfUHAIpocRTKuCwBrncaBozHQwD
+O3Dk33AhZ6lqTv/AVLRKHQXwigGTBJxK4cCEZ+VwK9tKk6BrQB48Rm7pg9HF5ey5
+JGPRWgUnn1v0IJN5ysZ5m9ChYbqF8VwvMw0txmgKgvdDKpXbF/S59Bp4TH/7Dr2D
+kAeNTcuzTFBaFE+siMgksZIYKZ1VkVoiN2qQA7ZaA5LQbUom0WdrKZGefFfPt9ES
+A4wyL3OfxRsmWmd/5Fxrwm1VbzgPoMd1Dc5ExlyqnecdGzDui2bmltNqRJd9ytRq
+6YUGYzXp4qQkWO61CoC3mkm2M8Ex7DGbUtXhdg0zoa08w9lXuOtHVhY7XlLWjO1U
+p8cp4DVxsN/wOXtyH1pcleGo4aEsgyU/DH57prFLGz7Egp2JhRDHnZmlonWp74G1
+VLfqkOqZlqTU4mPA827C8qPCx6cMsRvFS7OEiDBswkFWBKjkUCw4rLC1tBMBCxJW
+tZlc+Y0LNyOryJ3h6EJmRIHO57oLen345e1WOi4ROOC/wQMErFk7B3P41Lqmrwb8
+HGuKn3ca+Aw70hVrZ+7Q3RRFTLlOS/vv107Fqu6JAjkEEwEIACMFAlUh1AUCGwMH
+CwkIBwMCAQYVCAIJCgsEFgIDAQIeAQIXgAAKCRD8YkZDSHA05RfdD/97wPXnoe7e
+ipP7UXQ942z1buV6pTGv0Lea2aHn20o2BBjHp97YXroF/e/8W6h+Y+Fq8hWoXdYJ
+dC9DVgzJhvbXAIG8VrF6/IDGQ62r4ff/AIyQY+kiCOCCVhjwuqOTjVYw2pYRUcI3
+UwXVPeptDSXcIZkHCLtEUnS5YMTdkPuZrAmucCCnfcJtevXbHD2yJYP4vwfXMbal
+sNBDKJi6uYAFc4yv+/DyS13rfXJvu2pYGvtRd+fs7mBETvUTubhI440pIss6TX6M
+lxWexX6Ty8vI5HCQT281H4zqdbe5GdzGmIx1EiYx1sJbgSBNqCh5sRJY5/BXzVJ3
+dfM/Mv5QYY4ulO/qUNFdC8f1cZm0euOo3maB4jY+Sjaff7t0WIz0GufO4dHARwJg
+3s0LO9Wf5+z/fbWOMcfvvcfaHNbhaKWk16kslc/g7NYvMfOuleM06YGyGPz//a9c
+baX53OiMupNvLlhyPO5NfGppvRn5xAElcAw1RLhHJcgvTtIs/zVVfHPaK41u8A9c
+XKnmIUC39K4BGvOpPzEvCdQ2ZbAqzQLmZ1UICr15w1Nfs6uoERJbnuq+JgOPOcOk
+ezAWELi5LdZTElnpJpZPTDQ03+3GvxD4R9sR+l5RT8Ul7kF+3PPPzekfQzF+Nisr
+BhPFb2lPt3Hw32FgTTIuXCMRTKEBb/6z77kCDQRVIdQFARAAtmnsZ9A8ovJIJ9Rl
+WeIylEhHRyQifqzgc/r50uDZVPBjewOA462LjH3+F6zFGEkU+q2aqSe0A0SJPF/W
+hj6MNYXLoibxi5D4mGkoIao9ExnXt4LXAc6ogQpY6vFQBJU5Nr8XCefQbm0loa/o
+y5uK8JHLWCZ2jAossnVpzDwNeN27+B8h5+OifnWhQCTun1xz5EJiyc0yoBmf46zf
+mU4CMUBsPvrXcLmw4J3wp35qmrHg1tNyPhd7VBlikMrgtrWX9IaPZ40dnrGG/WjO
+FYB3CKxGb0pTCj7GC4ubxo2upeWZqHLmdIVc7Nzsfp8EcwJbTj+jZ2Zfq6F8y+je
+sbgh8CaxYn4hEs23aPYRq5H4/buVmZhUw3/AAL9ZmyX6AtAQ0HktVtQe7ykP7DLs
+EpeLG+vPJFY363QeDsLHwOoxnZSfGziVlB4N/KqIkixNWcFTG8GSE1zKcdJVNoW+
+3MB3+FtMZWUJhH0FyKg5qLaJCtC7Yo5gsddU+QCqTn6gcZBnMX5j4LaAmW4hh1RX
+ffwwsbfviK5uhXQCeUnbUaokieetDx4s6Kay6t9ahTRr0r/Z3VWzvr+xATxNWZzi
+xTdezCGOB2ycZ0vq4bKXBuN8CAyOy5X1hf7Rc1BiAVQCILHJDtz0Ak/Hax6DAa2A
+Hnx9YlugHQf000KroLEY+GaxqYEAEQEAAYkCHwQYAQgACQUCVSHUBQIbDAAKCRD8
+YkZDSHA05RtyEACdOEmGolL1xG6I+lDVdot6oBZqC9e021aLWqCUpWJFDp0m0aTm
+CfmOI1gTaFjScxhq1W0GPUoJKUZhk3tlVfdSCtUckI+xuWKEfqJYtvUtTXpK4jDe
+aZBovJ3KNpJRIynbr1566zCSQJhHiCGWmE/M5KN3gPsORbCBQXEkONSVsslf1Wm6
+6hU6uqSWUaceD+4fl5LClbck1DPWchAP7+uLKPEOtORyH6KRTgKl73zYo7xU1K4Q
+MN/1aMjobPkqNvvkXnUNwO7QMz18Nx+WqPc4ksJgW1O1aPQ2qL/ARY5jatZ6BBd7
+iytfz7d6JOh0FOIlmhBqbWd7fEGrLsSA+EjBGBwW5BnIMmxP1xhjhwrcI18y8kAK
+5UzdW2hbbAlc2rlsuxEc+xOYh8kGcc+mZ1j/aMn4gALsTbSO/0T+YJhfODNnL1dC
+j7oPbJGmmG6pb/o7P4azBUVC9lHOuV3XlAPjSmJylnNsV7+PxwPlXlvKgh4S4C4Z
+PUc/iPetsxXR2djccOoNxVU4CqJBqYKgul/pUphXkh7QfEKyH+42UETbVhstdBVU
+azJ6SeUnv9ClVDGsCEhfEZfNOnOoDzJGxDfESoAw7ih91vIhTyHHsK83p2HLDMLP
+ptLzx/0AFBfo6MWGGpd2RSnMWNbvh59wiThlDeI+Das3ln5nsAo67dMYdA==
+=fjOq
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/contrib/build-wine/make_win.sh b/contrib/build-wine/make_win.sh
new file mode 100755
index 000000000000..9ce28560300b
--- /dev/null
+++ b/contrib/build-wine/make_win.sh
@@ -0,0 +1,105 @@
+#!/bin/bash
+
+set -e
+
+here="$(dirname "$(readlink -e "$0")")"
+test -n "$here" -a -d "$here" || exit
+
+if [ -z "$WIN_ARCH" ] ; then
+ export WIN_ARCH="win64" # default
+fi
+if [ "$WIN_ARCH" = "win32" ] ; then
+ export GCC_TRIPLET_HOST="i686-w64-mingw32"
+elif [ "$WIN_ARCH" = "win64" ] ; then
+ export GCC_TRIPLET_HOST="x86_64-w64-mingw32"
+else
+ echo "unexpected WIN_ARCH: $WIN_ARCH"
+ exit 1
+fi
+
+export BUILD_TYPE="wine"
+export GCC_TRIPLET_BUILD="x86_64-pc-linux-gnu"
+export GCC_STRIP_BINARIES="1"
+
+export CONTRIB="$here/.."
+export PROJECT_ROOT="$CONTRIB/.."
+export CACHEDIR="$here/.cache/$WIN_ARCH/build"
+export PIP_CACHE_DIR="$here/.cache/$WIN_ARCH/wine_pip_cache"
+export WINE_PIP_CACHE_DIR="c:/electrum/contrib/build-wine/.cache/$WIN_ARCH/wine_pip_cache"
+export DLL_TARGET_DIR="$CACHEDIR/dlls"
+
+export WINEPREFIX="/opt/wine64"
+export WINEDEBUG=-all
+export WINE_PYHOME="c:/python3"
+export WINE_PYTHON="wine $WINE_PYHOME/python.exe -B"
+
+. "$CONTRIB"/build_tools_util.sh
+
+git -C "$PROJECT_ROOT" rev-parse 2>/dev/null || fail "Building outside a git clone is not supported."
+
+info "Clearing $here/build and $here/dist..."
+rm "$here"/build/* -rf
+rm "$here"/dist/* -rf
+
+mkdir -p "$CACHEDIR" "$DLL_TARGET_DIR" "$PIP_CACHE_DIR"
+
+if ls "$DLL_TARGET_DIR"/libsecp256k1-*.dll 1> /dev/null 2>&1; then
+ info "libsecp256k1 already built, skipping"
+else
+ "$CONTRIB"/make_libsecp256k1.sh || fail "Could not build libsecp"
+fi
+
+if [ -f "$DLL_TARGET_DIR/libzbar-0.dll" ]; then
+ info "libzbar already built, skipping"
+else
+ (
+ # iconv is needed for zbar. see https://github.com/mchehab/zbar/blob/a549566ea11eb03622bd4458a1728ffe3f589163/README-windows.md
+ # (previously were using win-iconv, but changed to GNU libiconv due to compilation errors with modern gcc)
+ LIBICONV_VER="1.18"
+ download_if_not_exist "$CACHEDIR/libiconv-${LIBICONV_VER}.tar.gz" "https://ftp.gnu.org/pub/gnu/libiconv/libiconv-${LIBICONV_VER}.tar.gz"
+ verify_hash "$CACHEDIR/libiconv-${LIBICONV_VER}.tar.gz" "3b08f5f4f9b4eb82f151a7040bfd6fe6c6fb922efe4b1659c66ea933276965e8"
+ tar xf "$CACHEDIR/libiconv-${LIBICONV_VER}.tar.gz" -C "$CACHEDIR"
+ # ref https://github.com/msys2/MINGW-packages/blob/7f68e9f2488737bbe03888ade094eaee8021d1c5/mingw-w64-libiconv/PKGBUILD
+ info "Building libiconv..."
+ cd "$CACHEDIR/libiconv-${LIBICONV_VER}"
+ # Patches taken from msys2/MINGW-packages
+ patch -p1 < "$here/patches/libiconv-fix-pointer-buf.patch"
+ ./configure \
+ $AUTOCONF_FLAGS \
+ --prefix="/usr/${GCC_TRIPLET_HOST}" \
+ --disable-static \
+ --enable-shared \
+ --enable-extra-encodings \
+ --enable-relocatable \
+ --disable-rpath \
+ --enable-silent-rules \
+ --enable-nls
+ CC="${GCC_TRIPLET_HOST}-gcc" make "-j$CPU_COUNT" || fail "Could not build libiconv"
+ cp -fpv "libcharset/lib/.libs/libcharset-1.dll" "$DLL_TARGET_DIR/" || fail "Could not copy the libcharset binary to DLL_TARGET_DIR"
+ cp -fpv "lib/.libs/libiconv-2.dll" "$DLL_TARGET_DIR/" || fail "Could not copy the libiconv binary to DLL_TARGET_DIR"
+ # FIXME avoid using sudo
+ sudo make install || fail "Could not install libiconv"
+ # workaround to delete files owned by root, created by "make install":
+ make clean
+ )
+ "$CONTRIB"/make_zbar.sh || fail "Could not build zbar"
+fi
+
+if [ -f "$DLL_TARGET_DIR/libusb-1.0.dll" ]; then
+ info "libusb already built, skipping"
+else
+ "$CONTRIB"/make_libusb.sh || fail "Could not build libusb"
+fi
+
+"$here/prepare-wine.sh" || fail "prepare-wine failed"
+
+info "Resetting modification time in C:\Python..."
+# (Because of some bugs in pyinstaller)
+pushd /opt/wine64/drive_c/python*
+find -exec touch -h -d '2000-11-11T11:11:11+00:00' {} +
+popd
+ls -l /opt/wine64/drive_c/python*
+
+"$here/build-electrum-git.sh" || fail "build-electrum-git failed"
+
+info "Done."
diff --git a/contrib/build-wine/patches/libiconv-fix-pointer-buf.patch b/contrib/build-wine/patches/libiconv-fix-pointer-buf.patch
new file mode 100644
index 000000000000..914b87bdf0e9
--- /dev/null
+++ b/contrib/build-wine/patches/libiconv-fix-pointer-buf.patch
@@ -0,0 +1,37 @@
+--- a/lib/iconv.c 2018-05-03 23:18:55.997221700 -0400
++++ b/lib/iconv.c 2018-05-03 23:26:47.611682700 -0400
+@@ -170,12 +170,12 @@ static const struct stringpool2_t string
+ #include "aliases2.h"
+ #undef S
+ };
+ #define stringpool2 ((const char *) &stringpool2_contents)
+ static const struct alias sysdep_aliases[] = {
+-#define S(tag,name,encoding_index) { (int)(long)&((struct stringpool2_t *)0)->stringpool_##tag, encoding_index },
++#define S(tag,name,encoding_index) { (int)(intptr_t)&((struct stringpool2_t *)0)->stringpool_##tag, encoding_index },
+ #include "aliases2.h"
+ #undef S
+ };
+ #ifdef __GNUC__
+ __inline
+ #else
+--- a/lib/genaliases.c 2023-01-14 00:00:00.000000000 +0000
++++ b/lib/genaliases.c 2023-01-14 10:18:00.000000000 +0000
+@@ -50,7 +50,7 @@
+ putc(c, out2);
+ }
+ }
+- fprintf(out2,"\")' tmp.h | sed -e 's|^.*\\(stringpool_str[0-9]*\\).*$| (int)(long)\\&((struct stringpool_t *)0)->\\1,|'\n");
++ fprintf(out2,"\")' tmp.h | sed -e 's|^.*\\(stringpool_str[0-9]*\\).*$| (int)(intptr_t)\\&((struct stringpool_t *)0)->\\1,|'\n");
+ for (; n > 0; names++, n--)
+ emit_alias(out1, *names, c_name);
+ }
+--- a/lib/genaliases2.c 2023-01-14 00:00:00.000000000 +0000
++++ b/lib/genaliases2.c 2023-01-14 10:18:00.000000000 +0000
+@@ -44,6 +44,6 @@
+ static void emit_encoding (FILE* out1, FILE* out2, const char* tag, const char* const* names, size_t n, const char* c_name)
+ {
+- fprintf(out2," (int)(long)&((struct stringpool2_t *)0)->stringpool_%s_%u,\n",tag,counter);
++ fprintf(out2," (int)(intptr_t)&((struct stringpool2_t *)0)->stringpool_%s_%u,\n",tag,counter);
+ for (; n > 0; names++, n--)
+ emit_alias(out1, tag, *names, c_name);
+ }
diff --git a/contrib/build-wine/prepare-hw.sh b/contrib/build-wine/prepare-hw.sh
deleted file mode 100755
index 1851b7b0ffd9..000000000000
--- a/contrib/build-wine/prepare-hw.sh
+++ /dev/null
@@ -1,28 +0,0 @@
-#!/bin/bash
-
-TREZOR_GIT_URL=https://github.com/trezor/python-trezor.git
-KEEPKEY_GIT_URL=https://github.com/keepkey/python-keepkey.git
-BTCHIP_GIT_URL=https://github.com/LedgerHQ/btchip-python.git
-
-BRANCH=master
-
-PYTHON_VERSION=3.5.4
-
-# These settings probably don't need any change
-export WINEPREFIX=/opt/wine64
-
-PYHOME=c:/python$PYTHON_VERSION
-PYTHON="wine $PYHOME/python.exe -OO -B"
-
-# Let's begin!
-cd `dirname $0`
-set -e
-
-cd tmp
-
-$PYTHON -m pip install setuptools --upgrade
-$PYTHON -m pip install cython --upgrade
-$PYTHON -m pip install trezor==0.7.16 --upgrade
-$PYTHON -m pip install keepkey==4.0.0 --upgrade
-$PYTHON -m pip install btchip-python==0.1.23 --upgrade
-
diff --git a/contrib/build-wine/prepare-pyinstaller.sh b/contrib/build-wine/prepare-pyinstaller.sh
deleted file mode 100755
index cf8a326cdf7f..000000000000
--- a/contrib/build-wine/prepare-pyinstaller.sh
+++ /dev/null
@@ -1,24 +0,0 @@
-#!/bin/bash
-PYTHON_VERSION=3.5.4
-
-PYINSTALLER_GIT_URL=https://github.com/ecdsa/pyinstaller.git
-BRANCH=fix_2952
-
-export WINEPREFIX=/opt/wine64
-PYHOME=c:/python$PYTHON_VERSION
-PYTHON="wine $PYHOME/python.exe -OO -B"
-
-cd `dirname $0`
-set -e
-cd tmp
-if [ ! -d "pyinstaller" ]; then
- git clone -b $BRANCH $PYINSTALLER_GIT_URL pyinstaller
-fi
-
-cd pyinstaller
-git pull
-git checkout $BRANCH
-$PYTHON setup.py install
-cd ..
-
-wine "C:/python$PYTHON_VERSION/scripts/pyinstaller.exe" -v
diff --git a/contrib/build-wine/prepare-wine.sh b/contrib/build-wine/prepare-wine.sh
index 542a2085a8c1..5d5328e56dff 100755
--- a/contrib/build-wine/prepare-wine.sh
+++ b/contrib/build-wine/prepare-wine.sh
@@ -1,113 +1,100 @@
#!/bin/bash
-# Please update these carefully, some versions won't work under Wine
-NSIS_URL=https://prdownloads.sourceforge.net/nsis/nsis-3.02.1-setup.exe?download
-NSIS_SHA256=736c9062a02e297e335f82252e648a883171c98e0d5120439f538c81d429552e
-PYTHON_VERSION=3.5.4
+PYINSTALLER_REPO="https://github.com/pyinstaller/pyinstaller.git"
+PYINSTALLER_COMMIT="306d4d92580fea7be7ff2c89ba112cdc6f73fac1"
+# ^ tag "v6.13.0"
-## These settings probably don't need change
-export WINEPREFIX=/opt/wine64
-#export WINEARCH='win32'
+PYTHON_VERSION=3.12.10
-PYHOME=c:/python$PYTHON_VERSION
-PYTHON="wine $PYHOME/python.exe -OO -B"
-
-
-# based on https://superuser.com/questions/497940/script-to-verify-a-signature-with-gpg
-verify_signature() {
- local file=$1 keyring=$2 out=
- if out=$(gpg --no-default-keyring --keyring "$keyring" --status-fd 1 --verify "$file" 2>/dev/null) &&
- echo "$out" | grep -qs "^\[GNUPG:\] VALIDSIG "; then
- return 0
- else
- echo "$out" >&2
- exit 0
- fi
-}
-
-verify_hash() {
- local file=$1 expected_hash=$2 out=
- actual_hash=$(sha256sum $file | awk '{print $1}')
- if [ "$actual_hash" == "$expected_hash" ]; then
- return 0
- else
- echo "$file $actual_hash (unexpected hash)" >&2
- exit 0
- fi
-}
# Let's begin!
-cd `dirname $0`
set -e
-# Clean up Wine environment
-echo "Cleaning $WINEPREFIX"
-rm -rf $WINEPREFIX
-echo "done"
+here="$(dirname "$(readlink -e "$0")")"
+. "$CONTRIB"/build_tools_util.sh
+
+info "Booting wine."
wine 'wineboot'
-echo "Cleaning tmp"
-rm -rf tmp
-mkdir -p tmp
-echo "done"
-cd tmp
+cd "$CACHEDIR"
+mkdir -p $WINEPREFIX/drive_c/tmp
-# Install Python
+info "Installing Python."
# note: you might need "sudo apt-get install dirmngr" for the following
# keys from https://www.python.org/downloads/#pubkeys
-KEYRING_PYTHON_DEV=keyring-electrum-build-python-dev.gpg
-gpg --no-default-keyring --keyring $KEYRING_PYTHON_DEV --recv-keys 531F072D39700991925FED0C0EDDC5F26A45C816 26DEA9D4613391EF3E25C9FF0A5B101836580288 CBC547978A3964D14B9AB36A6AF053F07D9DC8D2 C01E1CAD5EA2C4F0B8E3571504C367C218ADD4FF 12EF3DC38047DA382D18A5B999CDEA9DA4135B38 8417157EDBE73D9EAC1E539B126EB563A74B06BF DBBF2EEBF925FAADCF1F3FFFD9866941EA5BBD71 2BA0DB82515BBB9EFFAC71C5C9BE28DEE6DF025C 0D96DF4D4110E5C43FBFB17F2D347EA6AA65421D C9B104B3DD3AA72D7CCB1066FB9921286F5E1540 97FC712E4C024BBEA48A61ED3A5CA953F73C700D 7ED10B6531D7C8E1BC296021FC624643487034E5
-for msifile in core dev exe lib pip tools; do
+KEYRING_PYTHON_DEV="keyring-electrum-build-python-dev.gpg"
+gpg --no-default-keyring --keyring $KEYRING_PYTHON_DEV --import "$here"/gpg_keys/7ED10B6531D7C8E1BC296021FC624643487034E5.asc
+if [ "$WIN_ARCH" = "win32" ] ; then
+ PYARCH="win32"
+elif [ "$WIN_ARCH" = "win64" ] ; then
+ PYARCH="amd64"
+else
+ fail "unexpected WIN_ARCH: $WIN_ARCH"
+fi
+PYTHON_DOWNLOADS="$CACHEDIR/python$PYTHON_VERSION"
+mkdir -p "$PYTHON_DOWNLOADS"
+for msifile in core dev exe lib pip; do
echo "Installing $msifile..."
- wget "https://www.python.org/ftp/python/$PYTHON_VERSION/win32/${msifile}.msi"
- wget "https://www.python.org/ftp/python/$PYTHON_VERSION/win32/${msifile}.msi.asc"
- verify_signature "${msifile}.msi.asc" $KEYRING_PYTHON_DEV
- wine msiexec /i "${msifile}.msi" /qb TARGETDIR=C:/python$PYTHON_VERSION
+ download_if_not_exist "$PYTHON_DOWNLOADS/${msifile}.msi" "https://www.python.org/ftp/python/$PYTHON_VERSION/$PYARCH/${msifile}.msi"
+ download_if_not_exist "$PYTHON_DOWNLOADS/${msifile}.msi.asc" "https://www.python.org/ftp/python/$PYTHON_VERSION/$PYARCH/${msifile}.msi.asc"
+ verify_signature "$PYTHON_DOWNLOADS/${msifile}.msi.asc" $KEYRING_PYTHON_DEV || fail "invalid sig for ${msifile}.msi"
+ wine msiexec /i "$PYTHON_DOWNLOADS/${msifile}.msi" /qb TARGETDIR=$WINE_PYHOME || fail "wine msiexec failed for ${msifile}.msi"
done
-# upgrade pip
-$PYTHON -m pip install pip --upgrade
-
-# Install PyWin32
-$PYTHON -m pip install pypiwin32
-
-# Install PyQt
-$PYTHON -m pip install PyQt5
+break_legacy_easy_install
-## Install pyinstaller
-#$PYTHON -m pip install pyinstaller==3.3
+info "Installing build dependencies."
+$WINE_PYTHON -m pip install --no-build-isolation --no-dependencies --no-warn-script-location \
+ --cache-dir "$WINE_PIP_CACHE_DIR" -r "$CONTRIB"/deterministic-build/requirements-build-base.txt
+$WINE_PYTHON -m pip install --no-build-isolation --no-dependencies --no-binary :all: --no-warn-script-location \
+ --cache-dir "$WINE_PIP_CACHE_DIR" -r "$CONTRIB"/deterministic-build/requirements-build-wine.txt
-# Install ZBar
-#wget -q -O zbar.exe "https://sourceforge.net/projects/zbar/files/zbar/0.10/zbar-0.10-setup.exe/download"
-#wine zbar.exe
+# copy already built DLLs
+cp "$DLL_TARGET_DIR"/*.dll "$WINEPREFIX/drive_c/electrum/electrum/" || fail "Could not copy DLLs to destination"
-# install Cryptodome
-$PYTHON -m pip install pycryptodomex
-# install PySocks
-$PYTHON -m pip install win_inet_pton
-
-# install websocket (python2)
-$PYTHON -m pip install websocket-client
-
-# Upgrade setuptools (so Electrum can be installed later)
-$PYTHON -m pip install setuptools --upgrade
-
-# Install NSIS installer
-wget -q -O nsis.exe "$NSIS_URL"
-verify_hash nsis.exe $NSIS_SHA256
-wine nsis.exe /S
-
-# Install UPX
-#wget -O upx.zip "https://downloads.sourceforge.net/project/upx/upx/3.08/upx308w.zip"
-#unzip -o upx.zip
-#cp upx*/upx.exe .
-
-# add dlls needed for pyinstaller:
-cp $WINEPREFIX/drive_c/python$PYTHON_VERSION/Lib/site-packages/PyQt5/Qt/bin/* $WINEPREFIX/drive_c/python$PYTHON_VERSION/
-
-
-echo "Wine is configured. Please run prepare-pyinstaller.sh"
+info "Building PyInstaller."
+# we build our own PyInstaller boot loader as the default one has high
+# anti-virus false positives
+(
+ if [ "$WIN_ARCH" = "win32" ] ; then
+ PYINST_ARCH="32bit"
+ elif [ "$WIN_ARCH" = "win64" ] ; then
+ PYINST_ARCH="64bit"
+ else
+ fail "unexpected WIN_ARCH: $WIN_ARCH"
+ fi
+ if [ -f "$CACHEDIR/pyinstaller/PyInstaller/bootloader/Windows-$PYINST_ARCH-intel/runw.exe" ]; then
+ info "pyinstaller already built, skipping"
+ exit 0
+ fi
+ cd "$WINEPREFIX/drive_c/electrum"
+ ELECTRUM_COMMIT_HASH=$(git rev-parse HEAD)
+ cd "$CACHEDIR"
+ rm -rf pyinstaller
+ mkdir pyinstaller
+ cd pyinstaller
+ # Shallow clone
+ git init
+ git remote add origin $PYINSTALLER_REPO
+ git fetch --depth 1 origin $PYINSTALLER_COMMIT
+ 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
+ echo "const char *electrum_tag = \"tagged by Electrum@$ELECTRUM_COMMIT_HASH\";" >> ./bootloader/src/pyi_main.c
+ pushd bootloader
+ # cross-compile to Windows using host python
+ python3 ./waf all CC="${GCC_TRIPLET_HOST}-gcc" \
+ CFLAGS="-static"
+ popd
+ # sanity check bootloader is there:
+ [[ -e "PyInstaller/bootloader/Windows-$PYINST_ARCH-intel/runw.exe" ]] || fail "Could not find runw.exe in target dir!"
+)
+info "Installing PyInstaller."
+$WINE_PYTHON -m pip install --no-build-isolation --no-dependencies --no-warn-script-location ./pyinstaller
+
+info "Wine is configured."
diff --git a/contrib/build-wine/pyinstaller.spec b/contrib/build-wine/pyinstaller.spec
new file mode 100644
index 000000000000..91e34b989b19
--- /dev/null
+++ b/contrib/build-wine/pyinstaller.spec
@@ -0,0 +1,176 @@
+# -*- mode: python -*-
+import sys
+import os
+from typing import TYPE_CHECKING
+
+from PyInstaller.utils.hooks import collect_data_files, collect_submodules, collect_dynamic_libs, copy_metadata
+
+if TYPE_CHECKING:
+ from PyInstaller.building.build_main import Analysis, PYZ, EXE, COLLECT
+
+
+PYPKG="electrum"
+MAIN_SCRIPT="run_electrum"
+PROJECT_ROOT = "C:/electrum"
+ICONS_FILE=f"{PROJECT_ROOT}/{PYPKG}/gui/icons/electrum.ico"
+
+cmdline_name = os.environ.get("ELECTRUM_CMDLINE_NAME")
+if not cmdline_name:
+ raise Exception('no name')
+
+
+# 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(f"{PYPKG}.plugins")
+
+
+binaries = []
+# Workaround for "Retro Look":
+binaries += [b for b in collect_dynamic_libs('PyQt6') if 'qwindowsvista' in b[0]]
+# add libsecp256k1, libusb, etc:
+binaries += [(f"{PROJECT_ROOT}/{PYPKG}/*.dll", '.')]
+
+
+datas = [
+ (f"{PROJECT_ROOT}/{PYPKG}/*.json", PYPKG),
+ (f"{PROJECT_ROOT}/{PYPKG}/lnwire/*.csv", f"{PYPKG}/lnwire"),
+ (f"{PROJECT_ROOT}/{PYPKG}/wordlist/english.txt", f"{PYPKG}/wordlist"),
+ (f"{PROJECT_ROOT}/{PYPKG}/wordlist/slip39.txt", f"{PYPKG}/wordlist"),
+ (f"{PROJECT_ROOT}/{PYPKG}/chains", f"{PYPKG}/chains"),
+ (f"{PROJECT_ROOT}/{PYPKG}/locale", f"{PYPKG}/locale"),
+ (f"{PROJECT_ROOT}/{PYPKG}/plugins", f"{PYPKG}/plugins"),
+ (f"{PROJECT_ROOT}/{PYPKG}/gui/icons", f"{PYPKG}/gui/icons"),
+ (f"{PROJECT_ROOT}/{PYPKG}/gui/fonts", f"{PYPKG}/gui/fonts"),
+]
+datas += collect_data_files(f"{PYPKG}.plugins")
+datas += collect_data_files('trezorlib') # TODO is this needed? and same question for other hww libs
+datas += collect_data_files('safetlib')
+datas += collect_data_files('ckcc')
+datas += collect_data_files('bitbox02')
+
+# some deps rely on importlib metadata
+datas += copy_metadata('slip10') # from trezor->slip10
+datas += copy_metadata('trezor')
+
+# Exclude parts of Qt that we never use. Reduces binary size by tens of MBs. see #4815
+excludes = [
+ "PyQt6.QtBluetooth",
+ "PyQt6.QtDesigner",
+ "PyQt6.QtNfc",
+ "PyQt6.QtPositioning",
+ "PyQt6.QtQml",
+ "PyQt6.QtQuick",
+ "PyQt6.QtQuick3D",
+ "PyQt6.QtQuickWidgets",
+ "PyQt6.QtRemoteObjects",
+ "PyQt6.QtSensors",
+ "PyQt6.QtSerialPort",
+ "PyQt6.QtSpatialAudio",
+ "PyQt6.QtSql",
+ "PyQt6.QtTest",
+ "PyQt6.QtTextToSpeech",
+ "PyQt6.QtWebChannel",
+ "PyQt6.QtWebSockets",
+ "PyQt6.QtXml",
+ # "PyQt6.QtNetwork", # needed by QtMultimedia. kinda weird but ok.
+]
+
+# 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([f"{PROJECT_ROOT}/{MAIN_SCRIPT}",
+ f"{PROJECT_ROOT}/{PYPKG}/gui/qt/main_window.py",
+ f"{PROJECT_ROOT}/{PYPKG}/gui/qt/qrreader/qtmultimedia/camera_dialog.py",
+ f"{PROJECT_ROOT}/{PYPKG}/gui/text.py",
+ f"{PROJECT_ROOT}/{PYPKG}/util.py",
+ f"{PROJECT_ROOT}/{PYPKG}/wallet.py",
+ f"{PROJECT_ROOT}/{PYPKG}/simple_config.py",
+ f"{PROJECT_ROOT}/{PYPKG}/bitcoin.py",
+ f"{PROJECT_ROOT}/{PYPKG}/dnssec.py",
+ f"{PROJECT_ROOT}/{PYPKG}/commands.py",
+ ],
+ binaries=binaries,
+ datas=datas,
+ hiddenimports=hiddenimports,
+ hookspath=[],
+ excludes=excludes,
+ )
+
+
+# http://stackoverflow.com/questions/19055089/pyinstaller-onefile-warning-pyconfig-h-when-importing-scipy-or-scipy-signal
+for d in a.datas:
+ if 'pyconfig' in d[0]:
+ a.datas.remove(d)
+ break
+
+
+# hotfix for #3171 (pre-Win10 binaries)
+a.binaries = [x for x in a.binaries if not x[1].lower().startswith(r'c:\windows')]
+
+pyz = PYZ(a.pure)
+
+
+#####
+# "standalone" exe with all dependencies packed into it
+
+exe_standalone = EXE(
+ pyz,
+ a.scripts,
+ a.binaries,
+ a.datas,
+ name=os.path.join("build", "pyi.win32", PYPKG, f"{cmdline_name}.exe"),
+ debug=False,
+ strip=None,
+ upx=False,
+ icon=ICONS_FILE,
+ console=False)
+ # console=True makes an annoying black box pop up, but it does make Electrum output command line commands, with this turned off no output will be given but commands can still be used
+
+exe_portable = EXE(
+ pyz,
+ a.scripts,
+ a.binaries,
+ a.datas + [('is_portable', 'README.md', 'DATA')],
+ name=os.path.join("build", "pyi.win32", PYPKG, f"{cmdline_name}-portable.exe"),
+ debug=False,
+ strip=None,
+ upx=False,
+ icon=ICONS_FILE,
+ console=False)
+
+#####
+# exe and separate files that NSIS uses to build installer "setup" exe
+
+exe_inside_setup_noconsole = EXE(
+ pyz,
+ a.scripts,
+ exclude_binaries=True,
+ name=os.path.join("build", "pyi.win32", PYPKG, f"{cmdline_name}.exe"),
+ debug=False,
+ strip=None,
+ upx=False,
+ icon=ICONS_FILE,
+ console=False)
+
+exe_inside_setup_console = EXE(
+ pyz,
+ a.scripts,
+ exclude_binaries=True,
+ name=os.path.join("build", "pyi.win32", PYPKG, f"{cmdline_name}-debug.exe"),
+ debug=False,
+ strip=None,
+ upx=False,
+ icon=ICONS_FILE,
+ console=True)
+
+coll = COLLECT(
+ exe_inside_setup_noconsole,
+ exe_inside_setup_console,
+ a.binaries,
+ a.zipfiles,
+ a.datas,
+ strip=None,
+ upx=True,
+ debug=False,
+ icon=ICONS_FILE,
+ console=False,
+ name=os.path.join('dist', PYPKG))
diff --git a/contrib/build-wine/sign.sh b/contrib/build-wine/sign.sh
new file mode 100755
index 000000000000..ecdd30575dcf
--- /dev/null
+++ b/contrib/build-wine/sign.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+
+set -e
+
+here="$(dirname "$0")"
+if [ -z "$WIN_SIGNING_PASSWORD" ]; then
+ echo "password missing"
+ exit 1
+fi
+
+test -n "$here" -a -d "$here" || exit
+cd $here
+
+CERT_FILE=${CERT_FILE:-~/codesigning/cert.pem}
+KEY_FILE=${KEY_FILE:-~/codesigning/key.pem}
+if [[ ! -f "$CERT_FILE" ]]; then
+ ls "$CERT_FILE"
+ echo "Make sure that $CERT_FILE and $KEY_FILE exist"
+fi
+
+if ! which osslsigncode > /dev/null 2>&1; then
+ echo "Please install osslsigncode"
+fi
+
+rm -rf signed
+mkdir -p signed >/dev/null 2>&1
+
+cd dist
+echo "Found $(ls *.exe | wc -w) files to sign."
+
+for f in $(ls *.exe); do
+ echo "Signing $f..."
+ osslsigncode sign \
+ -pass "$WIN_SIGNING_PASSWORD" \
+ -h sha256 \
+ -certs "$CERT_FILE" \
+ -key "$KEY_FILE" \
+ -n "Electrum" \
+ -i "https://electrum.org/" \
+ -t "http://timestamp.digicert.com/" \
+ -in "$f" \
+ -out "../signed/$f"
+ ls "../signed/$f" -lah
+done
diff --git a/contrib/build-wine/unsign.sh b/contrib/build-wine/unsign.sh
new file mode 100755
index 000000000000..f835467a5dbc
--- /dev/null
+++ b/contrib/build-wine/unsign.sh
@@ -0,0 +1,47 @@
+#!/bin/bash
+
+# exit if command fails
+set -e
+
+PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/../.."
+CONTRIB="$PROJECT_ROOT/contrib"
+here="$(dirname "$0")"
+test -n "$here" -a -d "$here" || exit
+cd "$here"
+
+if ! which osslsigncode > /dev/null 2>&1; then
+ echo "Please install osslsigncode"
+ exit 1
+fi
+
+rm -rf signed/stripped
+mkdir -p signed >/dev/null 2>&1
+mkdir -p signed/stripped >/dev/null 2>&1
+
+version=$("$CONTRIB"/print_electrum_version.py)
+
+echo "Found $(ls dist/*.exe | wc -w) files to verify."
+
+for mine in dist/*.exe; do
+ echo "---------------"
+ f="$(basename "$mine")"
+ if test -f "signed/$f"; then
+ echo "Found file at signed/$f"
+ else
+ echo "Downloading https://download.electrum.org/$version/$f"
+ wget -q "https://download.electrum.org/$version/$f" -O "signed/$f"
+ fi
+ out="signed/stripped/$f"
+ # Remove PE signature from signed binary
+ osslsigncode remove-signature -in "signed/$f" -out "$out" > /dev/null 2>&1
+ chmod +x "$out"
+ if cmp -s "$out" "$mine"; then
+ echo "Success: $f"
+ #gpg --sign --armor --detach signed/$f
+ else
+ echo "Failure: $f"
+ exit 1
+ fi
+done
+
+exit 0
diff --git a/contrib/build_tools_util.sh b/contrib/build_tools_util.sh
new file mode 100755
index 000000000000..1e8d9c209488
--- /dev/null
+++ b/contrib/build_tools_util.sh
@@ -0,0 +1,199 @@
+#!/usr/bin/env bash
+
+set -e
+
+# Set a fixed umask as this leaks into docker containers
+umask 0022
+
+RED='\033[0;31m'
+BLUE='\033[0;34m'
+YELLOW='\033[0;33m'
+NC='\033[0m' # No Color
+function info {
+ printf "\r💬 ${BLUE}INFO:${NC} ${1}\n"
+}
+function fail {
+ printf "\r🗯 ${RED}ERROR:${NC} ${1}\n"
+ exit 1
+}
+function warn {
+ printf "\r⚠️ ${YELLOW}WARNING:${NC} ${1}\n"
+}
+
+
+# based on https://superuser.com/questions/497940/script-to-verify-a-signature-with-gpg
+function verify_signature() {
+ local file=$1 keyring=$2 out=
+ if out=$(gpg --no-default-keyring --keyring "$keyring" --status-fd 1 --verify "$file" 2>/dev/null) &&
+ echo "$out" | grep -qs "^\[GNUPG:\] VALIDSIG "; then
+ return 0
+ else
+ echo "$out" >&2
+ exit 1
+ fi
+}
+
+function verify_hash() {
+ local file=$1 expected_hash=$2
+ actual_hash=$(sha256sum "$file" | awk '{print $1}')
+ if [ "$actual_hash" == "$expected_hash" ]; then
+ return 0
+ else
+ echo "$file $actual_hash (unexpected hash)" >&2
+ rm "$file"
+ exit 1
+ fi
+}
+
+function download_if_not_exist() {
+ local file_name=$1 url=$2
+ if [ ! -e "$file_name" ] ; then
+ wget -O "$file_name" "$url"
+ fi
+}
+
+# Function to clone or update a git repository to a specific commit
+clone_or_update_repo() {
+ local repo_url=$1
+ local commit_hash=$2
+ local repo_dir=$3
+
+ if [ -z "$repo_url" ] || [ -z "$commit_hash" ] || [ -z "$repo_dir" ]; then
+ fail "clone_or_update_repo: invalid arguments: repo_url='$repo_url', commit_hash='$commit_hash', repo_dir='$repo_dir'"
+ fi
+
+ if [ -d "$repo_dir" ]; then
+ info "Repository $repo_url exists in $repo_dir, updating..."
+ git -C "$repo_dir" clean -ffxd >/dev/null 2>&1 || fail "Failed to clean repository $repo_dir"
+ git -C "$repo_dir" fetch --all >/dev/null 2>&1 || fail "Failed to fetch from repository"
+ git -C "$repo_dir" reset --hard "$commit_hash^{commit}" >/dev/null 2>&1 || fail "Failed to reset to commit $commit_hash"
+ else
+ info "Cloning repository: $repo_url to $repo_dir"
+ git clone "$repo_url" "$repo_dir" >/dev/null 2>&1 || fail "Failed to clone repository $repo_url"
+ git -C "$repo_dir" checkout "$commit_hash^{commit}" >/dev/null 2>&1 || fail "Failed to checkout commit $commit_hash"
+ fi
+}
+
+apply_patch() {
+ local patch=$1
+ local path=$2
+
+ if [ -z "$patch" ] || [ -z "$path" ]; then
+ fail "apply_patch: invalid arguments: patch='$patch', path='$path'"
+ fi
+
+ if [ -d "$path" ]; then
+ info "Patching: $patch"
+ cd "$path"
+ patch -p1 <"$patch"
+ cd -
+ else
+ fail "apply_patch: path='$path' not found"
+ fi
+}
+
+# https://github.com/travis-ci/travis-build/blob/master/lib/travis/build/templates/header.sh
+function retry() {
+ local result=0
+ local count=1
+ while [ $count -le 3 ]; do
+ [ $result -ne 0 ] && {
+ echo -e "\nThe command \"$@\" failed. Retrying, $count of 3.\n" >&2
+ }
+ ! { "$@"; result=$?; }
+ [ $result -eq 0 ] && break
+ count=$(($count + 1))
+ sleep 1
+ done
+
+ [ $count -gt 3 ] && {
+ echo -e "\nThe command \"$@\" failed 3 times.\n" >&2
+ }
+
+ return $result
+}
+
+function gcc_with_triplet()
+{
+ TRIPLET="$1"
+ CMD="$2"
+ shift 2
+ if [ -n "$TRIPLET" ] ; then
+ "$TRIPLET-$CMD" "$@"
+ else
+ "$CMD" "$@"
+ fi
+}
+
+function gcc_host()
+{
+ gcc_with_triplet "$GCC_TRIPLET_HOST" "$@"
+}
+
+function gcc_build()
+{
+ gcc_with_triplet "$GCC_TRIPLET_BUILD" "$@"
+}
+
+function host_strip()
+{
+ if [ "$GCC_STRIP_BINARIES" -ne "0" ] ; then
+ case "$BUILD_TYPE" in
+ linux|wine)
+ gcc_host strip "$@"
+ ;;
+ darwin)
+ # TODO: Strip on macOS?
+ ;;
+ esac
+ fi
+}
+
+# on MacOS, there is no realpath by default
+if ! [ -x "$(command -v realpath)" ]; then
+ function realpath() {
+ [[ $1 = /* ]] && echo "$1" || echo "$PWD/${1#./}"
+ }
+fi
+
+
+export SOURCE_DATE_EPOCH=1530212462
+export ZERO_AR_DATE=1 # for macOS
+export PYTHONHASHSEED=22
+# Set the build type, overridden by wine build
+export BUILD_TYPE="${BUILD_TYPE:-$(uname | tr '[:upper:]' '[:lower:]')}"
+# Add host / build flags if the triplets are set
+if [ -n "$GCC_TRIPLET_HOST" ] ; then
+ export AUTOCONF_FLAGS="$AUTOCONF_FLAGS --host=$GCC_TRIPLET_HOST"
+fi
+if [ -n "$GCC_TRIPLET_BUILD" ] ; then
+ export AUTOCONF_FLAGS="$AUTOCONF_FLAGS --build=$GCC_TRIPLET_BUILD"
+fi
+
+export GCC_STRIP_BINARIES="${GCC_STRIP_BINARIES:-0}"
+
+export CPU_COUNT="$(nproc 2> /dev/null || sysctl -n hw.ncpu)"
+info "Found $CPU_COUNT CPUs, which we might use for building."
+
+
+function break_legacy_easy_install() {
+ # We don't want setuptools sneakily installing dependencies, invisible to pip.
+ # This ensures that if setuptools calls distutils which then calls easy_install,
+ # easy_install will not download packages over the network.
+ # see https://pip.pypa.io/en/stable/reference/pip_install/#controlling-setup-requires
+ # see https://github.com/pypa/setuptools/issues/1916#issuecomment-743350566
+ info "Intentionally breaking legacy easy_install."
+ DISTUTILS_CFG="${HOME}/.pydistutils.cfg"
+ DISTUTILS_CFG_BAK="${HOME}/.pydistutils.cfg.orig"
+ # If we are not inside docker, we might be overwriting a config file on the user's system...
+ if [ -e "$DISTUTILS_CFG" ] && [ ! -e "$DISTUTILS_CFG_BAK" ]; then
+ warn "Overwriting python distutils config file at '$DISTUTILS_CFG'. A copy will be saved at '$DISTUTILS_CFG_BAK'."
+ mv "$DISTUTILS_CFG" "$DISTUTILS_CFG_BAK"
+ fi
+ cat < "$DISTUTILS_CFG"
+[easy_install]
+index_url = ''
+find_links = ''
+EOF
+}
+
diff --git a/contrib/ci/claude_security_review.py b/contrib/ci/claude_security_review.py
new file mode 100644
index 000000000000..09826568adaf
--- /dev/null
+++ b/contrib/ci/claude_security_review.py
@@ -0,0 +1,279 @@
+#!/usr/bin/env python3
+"""
+GitHub Actions job: Claude Code security review for Electrum pull requests.
+
+Runs Claude Code against the PR diff to detect critical security
+vulnerabilities. Optionally posts findings as a GitHub PR comment.
+
+Exit codes:
+ 0 -- PASS (no critical/high issues)
+ 1 -- FAIL (critical/high issues found)
+ 2 -- review could not run (infra error, logged as warning)
+
+Environment variables:
+ Required:
+ CLAUDE_CODE_OAUTH_TOKEN -- OAuth token from `claude setup-token` (MAX subscription)
+ Optional:
+ GITHUB_TOKEN -- GitHub token for posting PR comments
+ Set by the workflow:
+ PR_NUMBER -- PR number (empty if not a PR build)
+ BASE_BRANCH -- target branch of the PR
+ Set by GitHub Actions runtime:
+ GITHUB_REPOSITORY -- e.g. "spesmilo/electrum"
+ GITHUB_RUN_ID -- current workflow run ID
+ GITHUB_SERVER_URL -- e.g. "https://github.com"
+"""
+
+import json
+import os
+import re
+import subprocess
+import sys
+import urllib.error
+import urllib.request
+
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+PROMPT_FILE = os.path.join(SCRIPT_DIR, "security_review_prompt.md")
+
+MAX_DIFF_CHARS = 800_000
+CLAUDE_TIMEOUT_SECONDS = 60 * 60
+CLAUDE_MODEL = "claude-opus-4-8"
+CLAUDE_EFFORT = "max"
+
+VERDICT_PASS = "PASS"
+VERDICT_FAIL = "FAIL"
+
+
+def git(*args: str) -> str:
+ result = subprocess.run(
+ ["git"] + list(args),
+ capture_output=True, text=True, check=True,
+ )
+ return result.stdout
+
+
+def fetch_base_branch(base: str) -> None:
+ try:
+ git("fetch", "origin", base, "--depth=1")
+ except subprocess.CalledProcessError:
+ git("fetch", "origin", base)
+ # Shallow CI clones may lack the history needed for three-dot diff
+ # (merge-base computation). Unshallow if the merge-base is unreachable.
+ try:
+ git("merge-base", f"origin/{base}", "HEAD")
+ except subprocess.CalledProcessError:
+ try:
+ git("fetch", "--unshallow")
+ except subprocess.CalledProcessError:
+ pass # already a full clone
+
+
+def get_pr_diff(base: str) -> str:
+ return git("diff", f"origin/{base}...HEAD")
+
+
+def get_commit_messages(base: str) -> str:
+ return git("log", f"origin/{base}..HEAD")
+
+
+def changed_files_from_diff(diff: str) -> str:
+ return "\n".join(
+ m.group(1) for m in re.finditer(r"^diff --git a/.+ b/(.+)$", diff, re.MULTILINE)
+ )
+
+
+def read_system_prompt() -> str:
+ with open(PROMPT_FILE) as f:
+ return f.read()
+
+
+def build_user_prompt(diff: str, changed_files: str, commit_messages: str) -> str:
+ return (
+ "Review the following PR diff according to the review "
+ "guidelines in your system prompt.\n\n"
+ f"## Changed files\n\n```\n{changed_files}\n```\n\n"
+ f"## Commit messages\n\n```\n{commit_messages}\n```\n\n"
+ f"## Diff\n\n```diff\n{diff}\n```"
+ )
+
+
+def run_claude(user_prompt: str, system_prompt: str) -> str | None:
+ """Invoke Claude Code CLI in print mode. Returns review text or None on failure.
+
+ Passes the prompt via stdin to avoid OS argument length limits (MAX_ARG_STRLEN).
+ """
+ cmd = [
+ "claude",
+ "-p",
+ "--dangerously-skip-permissions",
+ "--model", CLAUDE_MODEL,
+ "--effort", CLAUDE_EFFORT,
+ "--output-format", "text",
+ "--append-system-prompt", system_prompt,
+ ]
+
+ try:
+ result = subprocess.run(
+ cmd,
+ input=user_prompt,
+ capture_output=True,
+ text=True,
+ timeout=CLAUDE_TIMEOUT_SECONDS,
+ )
+ except FileNotFoundError:
+ print("ERROR: 'claude' CLI not found. Is @anthropic-ai/claude-code installed?")
+ return None
+ except subprocess.TimeoutExpired:
+ print(f"ERROR: Claude Code timed out after {CLAUDE_TIMEOUT_SECONDS}s.")
+ return None
+
+ if result.returncode != 0:
+ print(f"ERROR: Claude Code exited with code {result.returncode}")
+ if result.stderr:
+ print(result.stderr)
+ return None
+
+ return result.stdout
+
+
+def parse_verdict(review: str) -> str | None:
+ for line in reversed(review.strip().splitlines()):
+ stripped = line.strip()
+ if stripped.startswith("VERDICT:"):
+ verdict = stripped.split(":", 1)[1].strip().upper()
+ if verdict in (VERDICT_PASS, VERDICT_FAIL):
+ return verdict
+ return None
+
+
+def post_github_comment(body: str, *, repo: str, pr: str) -> None:
+ """Post a comment on the PR. Silently skips if credentials are missing."""
+ token = os.environ.get("GITHUB_TOKEN", "").strip()
+ if not token:
+ print("GITHUB_TOKEN not set -- skipping PR comment.")
+ return
+
+ run_id = os.environ.get("GITHUB_RUN_ID", "")
+ server_url = os.environ.get("GITHUB_SERVER_URL", "https://github.com")
+ log_url = f"{server_url}/{repo}/actions/runs/{run_id}" if run_id else ""
+
+ comment = (
+ f"## Security Review -- Issues Found\n\n"
+ f"{body}\n\n"
+ f"---\n"
+ f"*Reviewed by Claude Code ({CLAUDE_MODEL}) at {CLAUDE_EFFORT} effort*"
+ )
+ if log_url:
+ comment += f" | [Full CI log]({log_url})"
+
+ url = f"https://api.github.com/repos/{repo}/issues/{pr}/comments"
+ data = json.dumps({"body": comment}).encode()
+ req = urllib.request.Request(
+ url,
+ data=data,
+ headers={
+ "Authorization": f"token {token}",
+ "Accept": "application/vnd.github.v3+json",
+ "Content-Type": "application/json",
+ },
+ method="POST",
+ )
+
+ try:
+ with urllib.request.urlopen(req) as resp:
+ if resp.status == 201:
+ print(f"Posted review comment on PR #{pr}.")
+ else:
+ print(f"GitHub API responded with status {resp.status}.")
+ except urllib.error.HTTPError as exc:
+ print(f"Failed to post PR comment: HTTP {exc.code} {exc.reason}")
+ except urllib.error.URLError as exc:
+ print(f"Failed to post PR comment: {exc.reason}")
+
+
+def main() -> int:
+ separator = "=" * 60
+
+ print(separator)
+ print("Claude Code Security Review")
+ print(separator)
+
+ pr = os.environ.get("PR_NUMBER", "").strip()
+ if not pr:
+ print("Not a PR build (PR_NUMBER is empty). Skipping.")
+ return 0
+
+ if not os.environ.get("CLAUDE_CODE_OAUTH_TOKEN", "").strip():
+ print("ERROR: CLAUDE_CODE_OAUTH_TOKEN is not set.")
+ return 2
+
+ base_branch = os.environ.get("BASE_BRANCH", "master").strip()
+ print(f"PR #{pr} -> base branch: {base_branch}")
+
+ print("\nFetching base branch...")
+ try:
+ fetch_base_branch(base_branch)
+ except subprocess.CalledProcessError as exc:
+ print(f"ERROR: git fetch failed: {exc}")
+ return 2
+
+ print("Computing diff...")
+ try:
+ diff = get_pr_diff(base_branch)
+ except subprocess.CalledProcessError as exc:
+ print(f"ERROR: git diff failed: {exc}")
+ return 2
+
+ if not diff or diff.isspace():
+ print("Empty diff -- nothing to review.")
+ return 0
+
+ try:
+ commit_messages = get_commit_messages(base_branch)
+ except subprocess.CalledProcessError as exc:
+ print(f"ERROR: git log failed: {exc}")
+ return 2
+
+ changed_files = changed_files_from_diff(diff)
+ file_count = len(changed_files.splitlines())
+ print(f"Reviewing changes across {file_count} file(s)...")
+
+ if len(diff) > MAX_DIFF_CHARS:
+ print(f"ERROR: diff is {len(diff)} chars, exceeds maximum of {MAX_DIFF_CHARS}. Skipping review.")
+ return 2
+
+ user_prompt = build_user_prompt(diff, changed_files, commit_messages)
+ system_prompt = read_system_prompt()
+
+ print(f"\nRunning Claude Code review (model: {CLAUDE_MODEL}) at {CLAUDE_EFFORT} effort...\n")
+ review = run_claude(user_prompt, system_prompt)
+
+ if review is None:
+ print("Review failed to produce output.")
+ return 2
+
+ print(separator)
+ print("REVIEW OUTPUT")
+ print(separator)
+ print(review)
+ print(separator)
+
+ verdict = parse_verdict(review)
+
+ if verdict == VERDICT_FAIL:
+ repo = os.environ.get("GITHUB_REPOSITORY", "").strip()
+ print("\nVERDICT: FAIL -- Critical or high severity issues found.")
+ post_github_comment(review, repo=repo, pr=pr)
+ return 1
+
+ if verdict == VERDICT_PASS:
+ print("\nVERDICT: PASS -- No critical or high severity issues.")
+ return 0
+
+ print("\nWARNING: Could not parse verdict from review output.")
+ print("Review logged above for manual inspection.")
+ return 2
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/contrib/ci/security_review_prompt.md b/contrib/ci/security_review_prompt.md
new file mode 100644
index 000000000000..e6fbce381d30
--- /dev/null
+++ b/contrib/ci/security_review_prompt.md
@@ -0,0 +1,106 @@
+# Electrum Security Review
+
+You are a security auditor reviewing a pull request diff for **Electrum**, a Bitcoin wallet
+that handles real funds on mainnet and Lightning Network. Your review must be thorough and
+precise -- but equally, it must not cry wolf. Only flag issues you are confident are real
+and exploitable in the context shown. A false positive that blocks a legitimate PR wastes
+developer time and erodes trust in this review.
+
+## Scope
+
+Focus your findings on the diff provided in the user message -- only flag issues introduced
+or worsened by changes in this PR. You have access to the full Electrum codebase; use it
+freely to read surrounding code, trace call chains, and understand what the diff actually
+does. But do not audit code outside the diff -- the codebase is context, not the review target.
+Focus on changes that introduce, worsen, or fail to mitigate security vulnerabilities.
+Only flag issues introduced or worsened by the diff. Do not flag
+pre-existing issues visible in context lines unless the change makes them newly exploitable.
+If the diff is truncated, review only what is provided and note the truncation in your summary.
+
+For each potential issue, consider whether it is actually exploitable given the context
+visible in the diff. Do not flag purely theoretical vulnerabilities that require
+preconditions impossible within Electrum's architecture. However, do account for
+sophisticated real-world attackers -- Electrum is a high-value target where supply-chain
+compromise, malicious Electrum servers, and rogue Lightning peers are realistic threat
+vectors.
+
+## Verifying commit message claims
+
+Use commit messages to understand intent -- but verify, do not trust them. If a
+commit message claims, in any phrasing, that it only **moves**, **relocates**,
+**renames**, **extracts**, **splits**, or otherwise rearranges code without
+behavioral change, strictly verify the claim against the diff: removed and added
+lines must match aside from cosmetic adjustments inherent to the move
+(indentation, import paths, file/module names). Any logic change, condition
+change, branch reordering, altered error handling, modified call signature, new
+side effect, or removed validation hiding inside such a commit must be flagged
+at the severity of the hidden change itself -- these are easy for human
+reviewers to miss. Explicitly note in the finding that the change was concealed
+inside a commit claiming to be a pure code move.
+
+## Severity Definitions
+
+### CRITICAL
+Issues that could directly cause loss of funds, exposure of private keys, remote code execution, denial of service, or phishing:
+- Private key, seed phrase, or wallet password leaked (to logs, error messages, network, disk in cleartext)
+- Cryptographic flaws: weak/predictable randomness, broken key derivation, nonce reuse, custom crypto primitives
+- Authentication or authorization bypass in JSON-RPC, wallet password checks, or plugin system
+- Transaction integrity: amount/fee manipulation, signature bypass, double-spend vectors
+- Lightning channel state corruption that could cause force-close fund loss
+- Denial of service: unbounded allocations, algorithmic complexity attacks, resource exhaustion from malicious server responses or peer messages, unbound loops or reads driven by untrusted input
+- Phishing vectors: untrusted strings from servers/peers displayed to users in error messages, dialogs, transaction descriptions, or notifications without sanitization -- an attacker-controlled server could craft messages that trick users into sending funds, revealing credentials, or taking dangerous actions
+- Obvious regressions: changes that clearly break existing functionality -- e.g. uncaught exceptions propagating to the user, broken control flow that makes a feature non-functional, or incorrect argument handling that would reliably crash at runtime
+
+### HIGH
+Issues that could be exploited with moderate effort or lead to significant damage:
+- Command injection, path traversal, or injection attacks (SQL, LDAP, XML)
+- Unsafe deserialization of data from network peers, Electrum servers, or untrusted files
+- Race conditions in wallet state, Lightning channel state machine, HTLC handling, or concurrent RPC
+- Integer overflow/underflow in financial calculations (amounts, fees, change outputs)
+- Insufficient validation of network protocol messages (Electrum protocol, Lightning BOLT messages, Nostr)
+- Hardcoded secrets, credentials, API keys, or debug backdoors
+- TOCTOU (time-of-check-time-of-use) vulnerabilities in file or wallet operations
+- Privacy leaks: unnecessary exposure of addresses, balances, transaction history, or wallet fingerprints to servers, peers, or third parties -- includes address reuse, unneeded network requests that correlate addresses, and identifiable user fingerprints.
+
+## Output Format
+
+Structure your review as follows:
+
+### If findings exist:
+
+For each finding, use this exact format:
+
+```
+### [SEVERITY] Short title
+- **File:** `filename.py` L123-L145 (or "multiple files" if applicable)
+- **Issue:** Clear description of the vulnerability
+- **Impact:** What an attacker could achieve by exploiting this
+- **Recommendation:** Specific fix suggestion
+```
+
+### Summary
+
+After all findings, provide a one-paragraph summary.
+
+### Verdict
+
+End your review with exactly one of these lines (no extra text on the same line):
+
+```
+VERDICT: FAIL
+```
+or
+```
+VERDICT: PASS
+```
+
+Rules:
+- `VERDICT: FAIL` if ANY **Critical** or **High** severity issues were found
+- `VERDICT: PASS` if no Critical or High severity issues were found
+- If the diff contains no security-relevant changes (documentation, comments, tests, locale files only), output:
+
+```
+No security-relevant changes detected in this diff.
+
+VERDICT: PASS
+```
diff --git a/contrib/deterministic-build/README.md b/contrib/deterministic-build/README.md
new file mode 100644
index 000000000000..bff073b02703
--- /dev/null
+++ b/contrib/deterministic-build/README.md
@@ -0,0 +1,12 @@
+# Notes
+
+The frozen dependency lists in this folder are *generated* files.
+
+- Starting from `contrib/requirements/requirements*.txt`,
+- we use the `contrib/freeze_packages.sh` script,
+- to generate `contrib/deterministic-build/requirements*.txt`.
+
+The source files list direct dependencies with loose version requirements,
+while the output files list all transitive dependencies with exact version+hash pins.
+
+The build scripts only use these hash pinned requirement files.
diff --git a/contrib/deterministic-build/check_submodules.sh b/contrib/deterministic-build/check_submodules.sh
new file mode 100755
index 000000000000..708d22c5e9f2
--- /dev/null
+++ b/contrib/deterministic-build/check_submodules.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+
+set -e
+
+PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/../.."
+LOCALE="$PROJECT_ROOT/electrum/locale/"
+
+cd "$PROJECT_ROOT"
+
+git submodule init
+git submodule update
+
+function get_git_mtime {
+ if [ $# -eq 1 ]; then
+ git log --pretty=%at -n1 -- $1
+ else
+ git log --pretty=%ar -n1 -- $2
+ fi
+}
+
+fail=0
+
+
+if [ $(date +%s -d "2 weeks ago") -gt $(get_git_mtime "$LOCALE") ]; then
+ echo "Last update from electrum-locale is older than 2 weeks."\
+ "Please update it to incorporate the latest translations from crowdin."
+ fail=1
+fi
+
+exit ${fail}
diff --git a/contrib/deterministic-build/find_restricted_dependencies.py b/contrib/deterministic-build/find_restricted_dependencies.py
new file mode 100755
index 000000000000..55bda1a426db
--- /dev/null
+++ b/contrib/deterministic-build/find_restricted_dependencies.py
@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+import sys
+from importlib.metadata import requires, PackageNotFoundError
+
+def is_dependency_edge_blacklisted(*, parent_pkg: str, dep: str) -> bool:
+ """Sometimes a package declares a hard dependency
+ for some niche functionality that we really do not care about.
+ """
+ dep = dep.lower()
+ parent_pkg = parent_pkg.lower()
+ return (parent_pkg, dep) in {
+ ("qrcode", "colorama"), # only needed for using qrcode-CLI on Windows.
+ ("click", "colorama"), # 'click' is a CLI tool, and it only needs colorama on Windows.
+ # In fact, we should blacklist 'click' itself, but that should be done elsewhere.
+ }
+
+
+def check_restriction(*, dep: str, restricted: str, parent_pkg: str):
+ # See: https://www.python.org/dev/peps/pep-0496/
+ # Hopefully we don't need to parse the whole microlanguage
+ if is_dependency_edge_blacklisted(dep=dep, parent_pkg=parent_pkg):
+ return False
+ if "extra" in restricted and "[" not in dep:
+ return False
+ for marker in ["os_name", "platform_release", "sys_platform", "platform_system"]:
+ if marker in restricted:
+ return True
+ return False
+
+
+def main():
+ for p in sys.stdin.read().split():
+ p = p.strip()
+ if not p:
+ continue
+ assert "==" in p, "This script expects a list of packages with pinned version, e.g. package==1.2.3, not {}".format(p)
+ pkg_name, _ = p.rsplit("==", 1)
+ try:
+ reqs = requires(pkg_name)
+ except PackageNotFoundError:
+ raise Exception("Package not found in this environment: {}. Install it first.".format(p))
+ if reqs is None:
+ continue
+ for r in reqs:
+ if ";" not in r:
+ continue
+ # example value for "r": "pefile (>=2017.8.1) ; sys_platform == \"win32\""
+ dep, restricted = r.split(";", 1)
+ dep = dep.strip()
+ restricted = restricted.strip()
+ dep_basename = dep.split(" ")[0]
+ if check_restriction(dep=dep, restricted=restricted, parent_pkg=pkg_name):
+ print(dep_basename, sep=" ")
+ print("Installing {} from {} although it is only needed for {}".format(dep, pkg_name, restricted), file=sys.stderr)
+
+if __name__ == "__main__":
+ main()
diff --git a/contrib/deterministic-build/requirements-binaries-mac.txt b/contrib/deterministic-build/requirements-binaries-mac.txt
new file mode 100644
index 000000000000..578af135c223
--- /dev/null
+++ b/contrib/deterministic-build/requirements-binaries-mac.txt
@@ -0,0 +1,153 @@
+cffi==1.17.1 \
+ --hash=sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8 \
+ --hash=sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2 \
+ --hash=sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1 \
+ --hash=sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15 \
+ --hash=sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36 \
+ --hash=sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824 \
+ --hash=sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8 \
+ --hash=sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36 \
+ --hash=sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17 \
+ --hash=sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf \
+ --hash=sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc \
+ --hash=sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3 \
+ --hash=sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed \
+ --hash=sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702 \
+ --hash=sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1 \
+ --hash=sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8 \
+ --hash=sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903 \
+ --hash=sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6 \
+ --hash=sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d \
+ --hash=sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b \
+ --hash=sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e \
+ --hash=sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be \
+ --hash=sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c \
+ --hash=sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683 \
+ --hash=sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9 \
+ --hash=sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c \
+ --hash=sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8 \
+ --hash=sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1 \
+ --hash=sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4 \
+ --hash=sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655 \
+ --hash=sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67 \
+ --hash=sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595 \
+ --hash=sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0 \
+ --hash=sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65 \
+ --hash=sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41 \
+ --hash=sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6 \
+ --hash=sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401 \
+ --hash=sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6 \
+ --hash=sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3 \
+ --hash=sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16 \
+ --hash=sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93 \
+ --hash=sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e \
+ --hash=sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4 \
+ --hash=sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964 \
+ --hash=sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c \
+ --hash=sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576 \
+ --hash=sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0 \
+ --hash=sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3 \
+ --hash=sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662 \
+ --hash=sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3 \
+ --hash=sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff \
+ --hash=sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5 \
+ --hash=sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd \
+ --hash=sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f \
+ --hash=sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5 \
+ --hash=sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14 \
+ --hash=sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d \
+ --hash=sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9 \
+ --hash=sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7 \
+ --hash=sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382 \
+ --hash=sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a \
+ --hash=sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e \
+ --hash=sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a \
+ --hash=sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4 \
+ --hash=sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99 \
+ --hash=sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87 \
+ --hash=sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b
+cryptography==45.0.3 \
+ --hash=sha256:00094838ecc7c6594171e8c8a9166124c1197b074cfca23645cee573910d76bc \
+ --hash=sha256:050ce5209d5072472971e6efbfc8ec5a8f9a841de5a4db0ebd9c2e392cb81972 \
+ --hash=sha256:232954730c362638544758a8160c4ee1b832dc011d2c41a306ad8f7cccc5bb0b \
+ --hash=sha256:25286aacb947286620a31f78f2ed1a32cded7be5d8b729ba3fb2c988457639e4 \
+ --hash=sha256:2f8f8f0b73b885ddd7f3d8c2b2234a7d3ba49002b0223f58cfde1bedd9563c56 \
+ --hash=sha256:38deed72285c7ed699864f964a3f4cf11ab3fb38e8d39cfcd96710cd2b5bb716 \
+ --hash=sha256:3ad69eeb92a9de9421e1f6685e85a10fbcfb75c833b42cc9bc2ba9fb00da4710 \
+ --hash=sha256:5555365a50efe1f486eed6ac7062c33b97ccef409f5970a0b6f205a7cfab59c8 \
+ --hash=sha256:555e5e2d3a53b4fabeca32835878b2818b3f23966a4efb0d566689777c5a12c8 \
+ --hash=sha256:57a6500d459e8035e813bd8b51b671977fb149a8c95ed814989da682314d0782 \
+ --hash=sha256:5833bb4355cb377ebd880457663a972cd044e7f49585aee39245c0d592904578 \
+ --hash=sha256:71320fbefd05454ef2d457c481ba9a5b0e540f3753354fff6f780927c25d19b0 \
+ --hash=sha256:7573d9eebaeceeb55285205dbbb8753ac1e962af3d9640791d12b36864065e71 \
+ --hash=sha256:92d5f428c1a0439b2040435a1d6bc1b26ebf0af88b093c3628913dd464d13fa1 \
+ --hash=sha256:97787952246a77d77934d41b62fb1b6f3581d83f71b44796a4158d93b8f5c490 \
+ --hash=sha256:9bb5bf55dcb69f7067d80354d0a348368da907345a2c448b0babc4215ccd3497 \
+ --hash=sha256:9cc80ce69032ffa528b5e16d217fa4d8d4bb7d6ba8659c1b4d74a1b0f4235fca \
+ --hash=sha256:9e4253ed8f5948a3589b3caee7ad9a5bf218ffd16869c516535325fece163dcc \
+ --hash=sha256:9eda14f049d7f09c2e8fb411dda17dd6b16a3c76a1de5e249188a32aeb92de19 \
+ --hash=sha256:a2b56de3417fd5f48773ad8e91abaa700b678dc7fe1e0c757e1ae340779acf7b \
+ --hash=sha256:af3f92b1dc25621f5fad065288a44ac790c5798e986a34d393ab27d2b27fcff9 \
+ --hash=sha256:c5edcb90da1843df85292ef3a313513766a78fbbb83f584a5a58fb001a5a9d57 \
+ --hash=sha256:c824c9281cb628015bfc3c59335163d4ca0540d49de4582d6c2637312907e4b1 \
+ --hash=sha256:c92519d242703b675ccefd0f0562eb45e74d438e001f8ab52d628e885751fb06 \
+ --hash=sha256:ca932e11218bcc9ef812aa497cdf669484870ecbcf2d99b765d6c27a86000942 \
+ --hash=sha256:cb6ab89421bc90e0422aca911c69044c2912fc3debb19bb3c1bfe28ee3dff6ab \
+ --hash=sha256:cfd84777b4b6684955ce86156cfb5e08d75e80dc2585e10d69e47f014f0a5342 \
+ --hash=sha256:d377dde61c5d67eb4311eace661c3efda46c62113ff56bf05e2d679e02aebb5b \
+ --hash=sha256:d54ae41e6bd70ea23707843021c778f151ca258081586f0cfa31d936ae43d1b2 \
+ --hash=sha256:dc10ec1e9f21f33420cc05214989544727e776286c1c16697178978327b95c9c \
+ --hash=sha256:ec21313dd335c51d7877baf2972569f40a4291b76a0ce51391523ae358d05899 \
+ --hash=sha256:ec64ee375b5aaa354b2b273c921144a660a511f9df8785e6d1c942967106438e \
+ --hash=sha256:ed43d396f42028c1f47b5fec012e9e12631266e3825e95c00e3cf94d472dac49 \
+ --hash=sha256:edd6d51869beb7f0d472e902ef231a9b7689508e83880ea16ca3311a00bf5ce7 \
+ --hash=sha256:f22af3c78abfbc7cbcdf2c55d23c3e022e1a462ee2481011d518c7fb9c9f3d65 \
+ --hash=sha256:fae1e637f527750811588e4582988932c222f8251f7b7ea93739acb624e1487f \
+ --hash=sha256:fed5aaca1750e46db870874c9c273cd5182a9e9deb16f06f7bdffdb5c2bde4b9
+pip==25.1.1 \
+ --hash=sha256:2913a38a2abf4ea6b64ab507bd9e967f3b53dc1ede74b01b0931e1ce548751af \
+ --hash=sha256:3de45d411d308d5054c2168185d8da7f9a2cd753dbac8acbfa88a8909ecd9077
+pycparser==2.22 \
+ --hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \
+ --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc
+PyQt6==6.6.1 \
+ --hash=sha256:03a656d5dc5ac31b6a9ad200f7f4f7ef49fa00ad7ce7a991b9bb691617141d12 \
+ --hash=sha256:5aa0e833cb5a79b93813f8181d9f145517dd5a46f4374544bcd1e93a8beec537 \
+ --hash=sha256:6b43878d0bbbcf8b7de165d305ec0cb87113c8930c92de748a11c473a6db5085 \
+ --hash=sha256:9f158aa29d205142c56f0f35d07784b8df0be28378d20a97bcda8bd64ffd0379
+PyQt6-Qt6==6.6.2 \
+ --hash=sha256:5a41fe9d53b9e29e9ec5c23f3c5949dba160f90ca313ee8b96b8ffe6a5059387 \
+ --hash=sha256:7ef446d3ffc678a8586ff6dc9f0d27caf4dff05dea02c353540d2f614386faf9 \
+ --hash=sha256:8d7f674a4ec43ca00191e14945ca4129acbe37a2172ed9d08214ad58b170bc11 \
+ --hash=sha256:b8363d88623342a72ac17da9127dc12f259bb3148796ea029762aa2d499778d9
+PyQt6-sip==13.10.2 \
+ --hash=sha256:061d4a2eb60a603d8be7db6c7f27eb29d9cea97a09aa4533edc1662091ce4f03 \
+ --hash=sha256:07f77e89d93747dda71b60c3490b00d754451729fbcbcec840e42084bf061655 \
+ --hash=sha256:0b097eb58b4df936c4a2a88a2f367c8bb5c20ff049a45a7917ad75d698e3b277 \
+ --hash=sha256:1a6c2f168773af9e6c7ef5e52907f16297d4efd346e4c958eda54ea9135be18e \
+ --hash=sha256:37af463dcce39285e686d49523d376994d8a2508b9acccb7616c4b117c9c4ed7 \
+ --hash=sha256:38b5823dca93377f8a4efac3cbfaa1d20229aa5b640c31cf6ebbe5c586333808 \
+ --hash=sha256:3dde8024d055f496eba7d44061c5a1ba4eb72fc95e5a9d7a0dbc908317e0888b \
+ --hash=sha256:45ac06f0380b7aa4fcffd89f9e8c00d1b575dc700c603446a9774fda2dcfc0de \
+ --hash=sha256:464ad156bf526500ce6bd05cac7a82280af6309974d816739b4a9a627156fafe \
+ --hash=sha256:4ccf197f8fa410e076936bee28ad9abadb450931d5be5625446fd20e0d8b27a6 \
+ --hash=sha256:4ffa71ddff6ef031d52cd4f88b8bba08b3516313c023c7e5825cf4a0ba598712 \
+ --hash=sha256:5506b9a795098df3b023cc7d0a37f93d3224a9c040c43804d4bc06e0b2b742b0 \
+ --hash=sha256:8132ec1cbbecc69d23dcff23916ec07218f1a9bbbc243bf6f1df967117ce303e \
+ --hash=sha256:83e6a56d3e715f748557460600ec342cbd77af89ec89c4f2a68b185fa14ea46c \
+ --hash=sha256:8b5d06a0eac36038fa8734657d99b5fe92263ae7a0cd0a67be6acfe220a063e1 \
+ --hash=sha256:9c67ed66e21b11e04ffabe0d93bc21df22e0a5d7e2e10ebc8c1d77d2f5042991 \
+ --hash=sha256:ad376a6078da37b049fdf9d6637d71b52727e65c4496a80b753ddc8d27526aca \
+ --hash=sha256:b1d3cc9015a1bd8c8d3e86a009591e897d4d46b0c514aede7d2970a2208749cd \
+ --hash=sha256:c7b34a495b92790c70eae690d9e816b53d3b625b45eeed6ae2c0fe24075a237e \
+ --hash=sha256:c80cc059d772c632f5319632f183e7578cd0976b9498682833035b18a3483e92 \
+ --hash=sha256:cc6a1dfdf324efaac6e7b890a608385205e652845c62130de919fd73a6326244 \
+ --hash=sha256:ddd578a8d975bfb5fef83751829bf09a97a1355fa1de098e4fb4d1b74ee872fc \
+ --hash=sha256:e455a181d45a28ee8d18d42243d4f470d269e6ccdee60f2546e6e71218e05bb4 \
+ --hash=sha256:e907394795e61f1174134465c889177f584336a98d7a10beade2437bf5942244
+setuptools==80.9.0 \
+ --hash=sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922 \
+ --hash=sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c
+wheel==0.45.1 \
+ --hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729 \
+ --hash=sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248
\ No newline at end of file
diff --git a/contrib/deterministic-build/requirements-binaries.txt b/contrib/deterministic-build/requirements-binaries.txt
new file mode 100644
index 000000000000..39edf3c0c6eb
--- /dev/null
+++ b/contrib/deterministic-build/requirements-binaries.txt
@@ -0,0 +1,157 @@
+cffi==1.17.1 \
+ --hash=sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8 \
+ --hash=sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2 \
+ --hash=sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1 \
+ --hash=sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15 \
+ --hash=sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36 \
+ --hash=sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824 \
+ --hash=sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8 \
+ --hash=sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36 \
+ --hash=sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17 \
+ --hash=sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf \
+ --hash=sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc \
+ --hash=sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3 \
+ --hash=sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed \
+ --hash=sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702 \
+ --hash=sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1 \
+ --hash=sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8 \
+ --hash=sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903 \
+ --hash=sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6 \
+ --hash=sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d \
+ --hash=sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b \
+ --hash=sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e \
+ --hash=sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be \
+ --hash=sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c \
+ --hash=sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683 \
+ --hash=sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9 \
+ --hash=sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c \
+ --hash=sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8 \
+ --hash=sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1 \
+ --hash=sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4 \
+ --hash=sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655 \
+ --hash=sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67 \
+ --hash=sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595 \
+ --hash=sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0 \
+ --hash=sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65 \
+ --hash=sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41 \
+ --hash=sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6 \
+ --hash=sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401 \
+ --hash=sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6 \
+ --hash=sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3 \
+ --hash=sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16 \
+ --hash=sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93 \
+ --hash=sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e \
+ --hash=sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4 \
+ --hash=sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964 \
+ --hash=sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c \
+ --hash=sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576 \
+ --hash=sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0 \
+ --hash=sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3 \
+ --hash=sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662 \
+ --hash=sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3 \
+ --hash=sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff \
+ --hash=sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5 \
+ --hash=sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd \
+ --hash=sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f \
+ --hash=sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5 \
+ --hash=sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14 \
+ --hash=sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d \
+ --hash=sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9 \
+ --hash=sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7 \
+ --hash=sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382 \
+ --hash=sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a \
+ --hash=sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e \
+ --hash=sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a \
+ --hash=sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4 \
+ --hash=sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99 \
+ --hash=sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87 \
+ --hash=sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b
+cryptography==45.0.3 \
+ --hash=sha256:00094838ecc7c6594171e8c8a9166124c1197b074cfca23645cee573910d76bc \
+ --hash=sha256:050ce5209d5072472971e6efbfc8ec5a8f9a841de5a4db0ebd9c2e392cb81972 \
+ --hash=sha256:232954730c362638544758a8160c4ee1b832dc011d2c41a306ad8f7cccc5bb0b \
+ --hash=sha256:25286aacb947286620a31f78f2ed1a32cded7be5d8b729ba3fb2c988457639e4 \
+ --hash=sha256:2f8f8f0b73b885ddd7f3d8c2b2234a7d3ba49002b0223f58cfde1bedd9563c56 \
+ --hash=sha256:38deed72285c7ed699864f964a3f4cf11ab3fb38e8d39cfcd96710cd2b5bb716 \
+ --hash=sha256:3ad69eeb92a9de9421e1f6685e85a10fbcfb75c833b42cc9bc2ba9fb00da4710 \
+ --hash=sha256:5555365a50efe1f486eed6ac7062c33b97ccef409f5970a0b6f205a7cfab59c8 \
+ --hash=sha256:555e5e2d3a53b4fabeca32835878b2818b3f23966a4efb0d566689777c5a12c8 \
+ --hash=sha256:57a6500d459e8035e813bd8b51b671977fb149a8c95ed814989da682314d0782 \
+ --hash=sha256:5833bb4355cb377ebd880457663a972cd044e7f49585aee39245c0d592904578 \
+ --hash=sha256:71320fbefd05454ef2d457c481ba9a5b0e540f3753354fff6f780927c25d19b0 \
+ --hash=sha256:7573d9eebaeceeb55285205dbbb8753ac1e962af3d9640791d12b36864065e71 \
+ --hash=sha256:92d5f428c1a0439b2040435a1d6bc1b26ebf0af88b093c3628913dd464d13fa1 \
+ --hash=sha256:97787952246a77d77934d41b62fb1b6f3581d83f71b44796a4158d93b8f5c490 \
+ --hash=sha256:9bb5bf55dcb69f7067d80354d0a348368da907345a2c448b0babc4215ccd3497 \
+ --hash=sha256:9cc80ce69032ffa528b5e16d217fa4d8d4bb7d6ba8659c1b4d74a1b0f4235fca \
+ --hash=sha256:9e4253ed8f5948a3589b3caee7ad9a5bf218ffd16869c516535325fece163dcc \
+ --hash=sha256:9eda14f049d7f09c2e8fb411dda17dd6b16a3c76a1de5e249188a32aeb92de19 \
+ --hash=sha256:a2b56de3417fd5f48773ad8e91abaa700b678dc7fe1e0c757e1ae340779acf7b \
+ --hash=sha256:af3f92b1dc25621f5fad065288a44ac790c5798e986a34d393ab27d2b27fcff9 \
+ --hash=sha256:c5edcb90da1843df85292ef3a313513766a78fbbb83f584a5a58fb001a5a9d57 \
+ --hash=sha256:c824c9281cb628015bfc3c59335163d4ca0540d49de4582d6c2637312907e4b1 \
+ --hash=sha256:c92519d242703b675ccefd0f0562eb45e74d438e001f8ab52d628e885751fb06 \
+ --hash=sha256:ca932e11218bcc9ef812aa497cdf669484870ecbcf2d99b765d6c27a86000942 \
+ --hash=sha256:cb6ab89421bc90e0422aca911c69044c2912fc3debb19bb3c1bfe28ee3dff6ab \
+ --hash=sha256:cfd84777b4b6684955ce86156cfb5e08d75e80dc2585e10d69e47f014f0a5342 \
+ --hash=sha256:d377dde61c5d67eb4311eace661c3efda46c62113ff56bf05e2d679e02aebb5b \
+ --hash=sha256:d54ae41e6bd70ea23707843021c778f151ca258081586f0cfa31d936ae43d1b2 \
+ --hash=sha256:dc10ec1e9f21f33420cc05214989544727e776286c1c16697178978327b95c9c \
+ --hash=sha256:ec21313dd335c51d7877baf2972569f40a4291b76a0ce51391523ae358d05899 \
+ --hash=sha256:ec64ee375b5aaa354b2b273c921144a660a511f9df8785e6d1c942967106438e \
+ --hash=sha256:ed43d396f42028c1f47b5fec012e9e12631266e3825e95c00e3cf94d472dac49 \
+ --hash=sha256:edd6d51869beb7f0d472e902ef231a9b7689508e83880ea16ca3311a00bf5ce7 \
+ --hash=sha256:f22af3c78abfbc7cbcdf2c55d23c3e022e1a462ee2481011d518c7fb9c9f3d65 \
+ --hash=sha256:fae1e637f527750811588e4582988932c222f8251f7b7ea93739acb624e1487f \
+ --hash=sha256:fed5aaca1750e46db870874c9c273cd5182a9e9deb16f06f7bdffdb5c2bde4b9
+pip==25.1.1 \
+ --hash=sha256:2913a38a2abf4ea6b64ab507bd9e967f3b53dc1ede74b01b0931e1ce548751af \
+ --hash=sha256:3de45d411d308d5054c2168185d8da7f9a2cd753dbac8acbfa88a8909ecd9077
+pycparser==2.22 \
+ --hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \
+ --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc
+PyQt6==6.9.0 \
+ --hash=sha256:0c8b7251608e05b479cfe731f95857e853067459f7cbbcfe90f89de1bcf04280 \
+ --hash=sha256:1cbc5a282454cf19691be09eadbde019783f1ae0523e269b211b0173b67373f6 \
+ --hash=sha256:5344240747e81bde1a4e0e98d4e6e2d96ad56a985d8f36b69cd529c1ca9ff760 \
+ --hash=sha256:6a8ff8e3cd18311bb7d937f7d741e787040ae7ff47ce751c28a94c5cddc1b4e6 \
+ --hash=sha256:d36482000f0cd7ce84a35863766f88a5e671233d5f1024656b600cd8915b3752 \
+ --hash=sha256:e344868228c71fc89a0edeb325497df4ff731a89cfa5fe57a9a4e9baecc9512b
+PyQt6-Qt6==6.9.0 \
+ --hash=sha256:1188f118d1c570d27fba39707e3d8a48525f979816e73de0da55b9e6fa9ad0a1 \
+ --hash=sha256:6d3875119dec6bf5f799facea362aa0ad39bb23aa9654112faa92477abccb5ff \
+ --hash=sha256:9c0e603c934e4f130c110190fbf2c482ff1221a58317266570678bc02db6b152 \
+ --hash=sha256:b1c4e4a78f0f22fbf88556e3d07c99e5ce93032feae5c1e575958d914612e0f9 \
+ --hash=sha256:c825a6f5a9875ef04ef6681eda16aa3a9e9ad71847aa78dfafcf388c8007aa0a \
+ --hash=sha256:cf840e8ae20a0704e0343810cf0e485552db28bf09ea976e58ec0e9b7bb27fcd
+PyQt6-sip==13.10.2 \
+ --hash=sha256:061d4a2eb60a603d8be7db6c7f27eb29d9cea97a09aa4533edc1662091ce4f03 \
+ --hash=sha256:07f77e89d93747dda71b60c3490b00d754451729fbcbcec840e42084bf061655 \
+ --hash=sha256:0b097eb58b4df936c4a2a88a2f367c8bb5c20ff049a45a7917ad75d698e3b277 \
+ --hash=sha256:1a6c2f168773af9e6c7ef5e52907f16297d4efd346e4c958eda54ea9135be18e \
+ --hash=sha256:37af463dcce39285e686d49523d376994d8a2508b9acccb7616c4b117c9c4ed7 \
+ --hash=sha256:38b5823dca93377f8a4efac3cbfaa1d20229aa5b640c31cf6ebbe5c586333808 \
+ --hash=sha256:3dde8024d055f496eba7d44061c5a1ba4eb72fc95e5a9d7a0dbc908317e0888b \
+ --hash=sha256:45ac06f0380b7aa4fcffd89f9e8c00d1b575dc700c603446a9774fda2dcfc0de \
+ --hash=sha256:464ad156bf526500ce6bd05cac7a82280af6309974d816739b4a9a627156fafe \
+ --hash=sha256:4ccf197f8fa410e076936bee28ad9abadb450931d5be5625446fd20e0d8b27a6 \
+ --hash=sha256:4ffa71ddff6ef031d52cd4f88b8bba08b3516313c023c7e5825cf4a0ba598712 \
+ --hash=sha256:5506b9a795098df3b023cc7d0a37f93d3224a9c040c43804d4bc06e0b2b742b0 \
+ --hash=sha256:8132ec1cbbecc69d23dcff23916ec07218f1a9bbbc243bf6f1df967117ce303e \
+ --hash=sha256:83e6a56d3e715f748557460600ec342cbd77af89ec89c4f2a68b185fa14ea46c \
+ --hash=sha256:8b5d06a0eac36038fa8734657d99b5fe92263ae7a0cd0a67be6acfe220a063e1 \
+ --hash=sha256:9c67ed66e21b11e04ffabe0d93bc21df22e0a5d7e2e10ebc8c1d77d2f5042991 \
+ --hash=sha256:ad376a6078da37b049fdf9d6637d71b52727e65c4496a80b753ddc8d27526aca \
+ --hash=sha256:b1d3cc9015a1bd8c8d3e86a009591e897d4d46b0c514aede7d2970a2208749cd \
+ --hash=sha256:c7b34a495b92790c70eae690d9e816b53d3b625b45eeed6ae2c0fe24075a237e \
+ --hash=sha256:c80cc059d772c632f5319632f183e7578cd0976b9498682833035b18a3483e92 \
+ --hash=sha256:cc6a1dfdf324efaac6e7b890a608385205e652845c62130de919fd73a6326244 \
+ --hash=sha256:ddd578a8d975bfb5fef83751829bf09a97a1355fa1de098e4fb4d1b74ee872fc \
+ --hash=sha256:e455a181d45a28ee8d18d42243d4f470d269e6ccdee60f2546e6e71218e05bb4 \
+ --hash=sha256:e907394795e61f1174134465c889177f584336a98d7a10beade2437bf5942244
+setuptools==80.9.0 \
+ --hash=sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922 \
+ --hash=sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c
+wheel==0.45.1 \
+ --hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729 \
+ --hash=sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248
\ No newline at end of file
diff --git a/contrib/deterministic-build/requirements-build-android.txt b/contrib/deterministic-build/requirements-build-android.txt
new file mode 100644
index 000000000000..e1c2361a524f
--- /dev/null
+++ b/contrib/deterministic-build/requirements-build-android.txt
@@ -0,0 +1,30 @@
+appdirs==1.4.4 \
+ --hash=sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41
+colorama==0.4.5 \
+ --hash=sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4
+Cython==0.29.37 \
+ --hash=sha256:f813d4a6dd94adee5d4ff266191d1d95bf6d4164a4facc535422c021b2504cfb
+Jinja2==3.1.6 \
+ --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d
+MarkupSafe==3.0.2 \
+ --hash=sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0
+pep517==0.13.1 \
+ --hash=sha256:1b2fa2ffd3938bb4beffe5d6146cbcb2bda996a5a4da9f31abffd8b24e07b317
+pexpect==4.9.0 \
+ --hash=sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f
+pip==25.1.1 \
+ --hash=sha256:3de45d411d308d5054c2168185d8da7f9a2cd753dbac8acbfa88a8909ecd9077
+ptyprocess==0.7.0 \
+ --hash=sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220
+setuptools==80.9.0 \
+ --hash=sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c
+sh==2.2.2 \
+ --hash=sha256:653227a7c41a284ec5302173fbc044ee817c7bad5e6e4d8d55741b9aeb9eb65b
+toml==0.10.2 \
+ --hash=sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f
+tomli==2.2.1 \
+ --hash=sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff
+typing-extensions==4.13.2 \
+ --hash=sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef
+wheel==0.45.1 \
+ --hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729
\ No newline at end of file
diff --git a/contrib/deterministic-build/requirements-build-appimage.txt b/contrib/deterministic-build/requirements-build-appimage.txt
new file mode 100644
index 000000000000..f266109bf587
--- /dev/null
+++ b/contrib/deterministic-build/requirements-build-appimage.txt
@@ -0,0 +1,8 @@
+Cython==3.1.1 \
+ --hash=sha256:505ccd413669d5132a53834d792c707974248088c4f60c497deb1b416e366397
+pip==25.1.1 \
+ --hash=sha256:3de45d411d308d5054c2168185d8da7f9a2cd753dbac8acbfa88a8909ecd9077
+setuptools==80.9.0 \
+ --hash=sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c
+wheel==0.45.1 \
+ --hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729
\ No newline at end of file
diff --git a/contrib/deterministic-build/requirements-build-base.txt b/contrib/deterministic-build/requirements-build-base.txt
new file mode 100644
index 000000000000..a41048373017
--- /dev/null
+++ b/contrib/deterministic-build/requirements-build-base.txt
@@ -0,0 +1,27 @@
+expandvars==1.0.0 \
+ --hash=sha256:f04070b8260264185f81142cd85e5df9ceef7229e836c5844302c4ccfa00c30d \
+ --hash=sha256:ff1690eceb90bbdeefd1e4b15f4d217f22a3e66f776c2cb060635d2dde4a7689
+flit-core==3.12.0 \
+ --hash=sha256:18f63100d6f94385c6ed57a72073443e1a71a4acb4339491615d0f16d6ff01b2 \
+ --hash=sha256:e7a0304069ea895172e3c7bb703292e992c5d1555dd1233ab7b5621b5b69e62c
+packaging==25.0 \
+ --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \
+ --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f
+pip==25.1.1 \
+ --hash=sha256:2913a38a2abf4ea6b64ab507bd9e967f3b53dc1ede74b01b0931e1ce548751af \
+ --hash=sha256:3de45d411d308d5054c2168185d8da7f9a2cd753dbac8acbfa88a8909ecd9077
+poetry-core==2.1.3 \
+ --hash=sha256:0522a015477ed622c89aad56a477a57813cace0c8e7ff2a2906b7ef4a2e296a4 \
+ --hash=sha256:2c704f05016698a54ca1d327f46ce2426d72eaca6ff614132c8477c292266771
+setuptools==80.9.0 \
+ --hash=sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922 \
+ --hash=sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c
+setuptools-scm==8.3.1 \
+ --hash=sha256:332ca0d43791b818b841213e76b1971b7711a960761c5bea5fc5cdb5196fbce3 \
+ --hash=sha256:3d555e92b75dacd037d32bafdf94f97af51ea29ae8c7b234cf94b7a5bd242a63
+tomli==2.0.2 \
+ --hash=sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38 \
+ --hash=sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed
+wheel==0.45.1 \
+ --hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729 \
+ --hash=sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248
\ No newline at end of file
diff --git a/contrib/deterministic-build/requirements-build-mac.txt b/contrib/deterministic-build/requirements-build-mac.txt
new file mode 100644
index 000000000000..9060f81eeaeb
--- /dev/null
+++ b/contrib/deterministic-build/requirements-build-mac.txt
@@ -0,0 +1,16 @@
+altgraph==0.17.4 \
+ --hash=sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406
+Cython==3.1.1 \
+ --hash=sha256:505ccd413669d5132a53834d792c707974248088c4f60c497deb1b416e366397
+macholib==1.16.3 \
+ --hash=sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30
+packaging==25.0 \
+ --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f
+pip==25.1.1 \
+ --hash=sha256:3de45d411d308d5054c2168185d8da7f9a2cd753dbac8acbfa88a8909ecd9077
+pyinstaller-hooks-contrib==2025.4 \
+ --hash=sha256:5ce1afd1997b03e70f546207031cfdf2782030aabacc102190677059e2856446
+setuptools==80.9.0 \
+ --hash=sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c
+wheel==0.45.1 \
+ --hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729
\ No newline at end of file
diff --git a/contrib/deterministic-build/requirements-build-wine.txt b/contrib/deterministic-build/requirements-build-wine.txt
new file mode 100644
index 000000000000..345402ccb7aa
--- /dev/null
+++ b/contrib/deterministic-build/requirements-build-wine.txt
@@ -0,0 +1,16 @@
+altgraph==0.17.4 \
+ --hash=sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406
+packaging==25.0 \
+ --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f
+pefile==2023.2.7 \
+ --hash=sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc
+pip==25.1.1 \
+ --hash=sha256:3de45d411d308d5054c2168185d8da7f9a2cd753dbac8acbfa88a8909ecd9077
+pyinstaller-hooks-contrib==2025.4 \
+ --hash=sha256:5ce1afd1997b03e70f546207031cfdf2782030aabacc102190677059e2856446
+pywin32-ctypes==0.2.3 \
+ --hash=sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755
+setuptools==80.9.0 \
+ --hash=sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c
+wheel==0.45.1 \
+ --hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729
\ No newline at end of file
diff --git a/contrib/deterministic-build/requirements-hw.txt b/contrib/deterministic-build/requirements-hw.txt
new file mode 100644
index 000000000000..b1d41c6e75fc
--- /dev/null
+++ b/contrib/deterministic-build/requirements-hw.txt
@@ -0,0 +1,429 @@
+base58==2.1.1 \
+ --hash=sha256:11a36f4d3ce51dfc1043f3218591ac4eb1ceb172919cebe05b52a5bcc8d245c2 \
+ --hash=sha256:c5d0cb3f5b6e81e8e35da5754388ddcc6d0d14b6c6a132cb93d69ed580a7278c
+bitbox02==7.0.0 \
+ --hash=sha256:27d5105eb15a553719fa9d3e68921c864b00c861b3a644044d9ac68426f18447 \
+ --hash=sha256:4b5b8422b94390b09962a4a93f4a9861429c093eb0f0b6c2d7661bbc1dd0e242
+cbor2==5.6.5 \
+ --hash=sha256:3038523b8fc7de312bb9cdcbbbd599987e64307c4db357cd2030c472a6c7d468 \
+ --hash=sha256:34cf5ab0dc310c3d0196caa6ae062dc09f6c242e2544bea01691fe60c0230596 \
+ --hash=sha256:37096663a5a1c46a776aea44906cbe5fa3952f29f50f349179c00525d321c862 \
+ --hash=sha256:38886c41bebcd7dca57739439455bce759f1e4c551b511f618b8e9c1295b431b \
+ --hash=sha256:3d1a18b3a58dcd9b40ab55c726160d4a6b74868f2a35b71f9e726268b46dc6a2 \
+ --hash=sha256:4586a4f65546243096e56a3f18f29d60752ee9204722377021b3119a03ed99ff \
+ --hash=sha256:47261f54a024839ec649b950013c4de5b5f521afe592a2688eebbe22430df1dc \
+ --hash=sha256:54c72a3207bb2d4480c2c39dad12d7971ce0853a99e3f9b8d559ce6eac84f66f \
+ --hash=sha256:559dcf0d897260a9e95e7b43556a62253e84550b77147a1ad4d2c389a2a30192 \
+ --hash=sha256:5b856fda4c50c5bc73ed3664e64211fa4f015970ed7a15a4d6361bd48462feaf \
+ --hash=sha256:5ce13a27ef8fddf643fc17a753fe34aa72b251d03c23da6a560c005dc171085b \
+ --hash=sha256:5cff06464b8f4ca6eb9abcba67bda8f8334a058abc01005c8e616728c387ad32 \
+ --hash=sha256:61ceb77e6aa25c11c814d4fe8ec9e3bac0094a1f5bd8a2a8c95694596ea01e08 \
+ --hash=sha256:66dd25dd919cddb0b36f97f9ccfa51947882f064729e65e6bef17c28535dc459 \
+ --hash=sha256:6797b824b26a30794f2b169c0575301ca9b74ae99064e71d16e6ba0c9057de51 \
+ --hash=sha256:6e14a1bf6269d25e02ef1d4008e0ce8880aa271d7c6b4c329dba48645764f60e \
+ --hash=sha256:73b9647eed1493097db6aad61e03d8f1252080ee041a1755de18000dd2c05f37 \
+ --hash=sha256:7488aec919f8408f9987a3a32760bd385d8628b23a35477917aa3923ff6ad45f \
+ --hash=sha256:7f6d69f38f7d788b04c09ef2b06747536624b452b3c8b371ab78ad43b0296fab \
+ --hash=sha256:824f202b556fc204e2e9a67d6d6d624e150fbd791278ccfee24e68caec578afd \
+ --hash=sha256:863e0983989d56d5071270790e7ed8ddbda88c9e5288efdb759aba2efee670bc \
+ --hash=sha256:87026fc838370d69f23ed8572939bd71cea2b3f6c8f8bb8283f573374b4d7f33 \
+ --hash=sha256:8f747b7a9aaa58881a0c5b4cd4a9b8fb27eca984ed261a769b61de1f6b5bd1e6 \
+ --hash=sha256:90bfa36944caccec963e6ab7e01e64e31cc6664535dc06e6295ee3937c999cbb \
+ --hash=sha256:93676af02bd9a0b4a62c17c5b20f8e9c37b5019b1a24db70a2ee6cb770423568 \
+ --hash=sha256:94885903105eec66d7efb55f4ce9884fdc5a4d51f3bd75b6fedc68c5c251511b \
+ --hash=sha256:97a7e409b864fecf68b2ace8978eb5df1738799a333ec3ea2b9597bfcdd6d7d2 \
+ --hash=sha256:a34ee99e86b17444ecbe96d54d909dd1a20e2da9f814ae91b8b71cf1ee2a95e4 \
+ --hash=sha256:a3ac50485cf67dfaab170a3e7b527630e93cb0a6af8cdaa403054215dff93adf \
+ --hash=sha256:a83b76367d1c3e69facbcb8cdf65ed6948678e72f433137b41d27458aa2a40cb \
+ --hash=sha256:a88f029522aec5425fc2f941b3df90da7688b6756bd3f0472ab886d21208acbd \
+ --hash=sha256:a8947c102cac79d049eadbd5e2ffb8189952890df7cbc3ee262bbc2f95b011a9 \
+ --hash=sha256:ae2b49226224e92851c333b91d83292ec62eba53a19c68a79890ce35f1230d70 \
+ --hash=sha256:b682820677ee1dbba45f7da11898d2720f92e06be36acec290867d5ebf3d7e09 \
+ --hash=sha256:b9d15b638539b68aa5d5eacc56099b4543a38b2d2c896055dccf7e83d24b7955 \
+ --hash=sha256:e16c4a87fc999b4926f5c8f6c696b0d251b4745bc40f6c5aee51d69b30b15ca2 \
+ --hash=sha256:e25c2aebc9db99af7190e2261168cdde8ed3d639ca06868e4f477cf3a228a8e9 \
+ --hash=sha256:f0d0a9c5aabd48ecb17acf56004a7542a0b8d8212be52f3102b8218284bd881e \
+ --hash=sha256:f2764804ffb6553283fc4afb10a280715905a4cea4d6dc7c90d3e89c4a93bc8d \
+ --hash=sha256:f4c7dbcdc59ea7f5a745d3e30ee5e6b6ff5ce7ac244aa3de6786391b10027bb3 \
+ --hash=sha256:f91e6d74fa6917df31f8757fdd0e154203b0dd0609ec53eb957016a2b474896a \
+ --hash=sha256:fa61a02995f3a996c03884cf1a0b5733f88cbfd7fa0e34944bf678d4227ee712 \
+ --hash=sha256:fde21ac1cf29336a31615a2c469a9cb03cf0add3ae480672d4d38cda467d07fc \
+ --hash=sha256:fe11c2eb518c882cfbeed456e7a552e544893c17db66fe5d3230dbeaca6b615c
+certifi==2025.4.26 \
+ --hash=sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6 \
+ --hash=sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3
+cffi==1.17.1 \
+ --hash=sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8 \
+ --hash=sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2 \
+ --hash=sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1 \
+ --hash=sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15 \
+ --hash=sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36 \
+ --hash=sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824 \
+ --hash=sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8 \
+ --hash=sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36 \
+ --hash=sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17 \
+ --hash=sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf \
+ --hash=sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc \
+ --hash=sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3 \
+ --hash=sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed \
+ --hash=sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702 \
+ --hash=sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1 \
+ --hash=sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8 \
+ --hash=sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903 \
+ --hash=sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6 \
+ --hash=sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d \
+ --hash=sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b \
+ --hash=sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e \
+ --hash=sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be \
+ --hash=sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c \
+ --hash=sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683 \
+ --hash=sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9 \
+ --hash=sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c \
+ --hash=sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8 \
+ --hash=sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1 \
+ --hash=sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4 \
+ --hash=sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655 \
+ --hash=sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67 \
+ --hash=sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595 \
+ --hash=sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0 \
+ --hash=sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65 \
+ --hash=sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41 \
+ --hash=sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6 \
+ --hash=sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401 \
+ --hash=sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6 \
+ --hash=sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3 \
+ --hash=sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16 \
+ --hash=sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93 \
+ --hash=sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e \
+ --hash=sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4 \
+ --hash=sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964 \
+ --hash=sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c \
+ --hash=sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576 \
+ --hash=sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0 \
+ --hash=sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3 \
+ --hash=sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662 \
+ --hash=sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3 \
+ --hash=sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff \
+ --hash=sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5 \
+ --hash=sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd \
+ --hash=sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f \
+ --hash=sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5 \
+ --hash=sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14 \
+ --hash=sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d \
+ --hash=sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9 \
+ --hash=sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7 \
+ --hash=sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382 \
+ --hash=sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a \
+ --hash=sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e \
+ --hash=sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a \
+ --hash=sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4 \
+ --hash=sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99 \
+ --hash=sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87 \
+ --hash=sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b
+charset-normalizer==3.4.2 \
+ --hash=sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4 \
+ --hash=sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45 \
+ --hash=sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7 \
+ --hash=sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0 \
+ --hash=sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7 \
+ --hash=sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d \
+ --hash=sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d \
+ --hash=sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0 \
+ --hash=sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184 \
+ --hash=sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db \
+ --hash=sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b \
+ --hash=sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64 \
+ --hash=sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b \
+ --hash=sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8 \
+ --hash=sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff \
+ --hash=sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344 \
+ --hash=sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58 \
+ --hash=sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e \
+ --hash=sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471 \
+ --hash=sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148 \
+ --hash=sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a \
+ --hash=sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836 \
+ --hash=sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e \
+ --hash=sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63 \
+ --hash=sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c \
+ --hash=sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1 \
+ --hash=sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01 \
+ --hash=sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366 \
+ --hash=sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58 \
+ --hash=sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5 \
+ --hash=sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c \
+ --hash=sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2 \
+ --hash=sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a \
+ --hash=sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597 \
+ --hash=sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b \
+ --hash=sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5 \
+ --hash=sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb \
+ --hash=sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f \
+ --hash=sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0 \
+ --hash=sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941 \
+ --hash=sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0 \
+ --hash=sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86 \
+ --hash=sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7 \
+ --hash=sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7 \
+ --hash=sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455 \
+ --hash=sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6 \
+ --hash=sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4 \
+ --hash=sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0 \
+ --hash=sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3 \
+ --hash=sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1 \
+ --hash=sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6 \
+ --hash=sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981 \
+ --hash=sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c \
+ --hash=sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980 \
+ --hash=sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645 \
+ --hash=sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7 \
+ --hash=sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12 \
+ --hash=sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa \
+ --hash=sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd \
+ --hash=sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef \
+ --hash=sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f \
+ --hash=sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2 \
+ --hash=sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d \
+ --hash=sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5 \
+ --hash=sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02 \
+ --hash=sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3 \
+ --hash=sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd \
+ --hash=sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e \
+ --hash=sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214 \
+ --hash=sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd \
+ --hash=sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a \
+ --hash=sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c \
+ --hash=sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681 \
+ --hash=sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba \
+ --hash=sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f \
+ --hash=sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a \
+ --hash=sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28 \
+ --hash=sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691 \
+ --hash=sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82 \
+ --hash=sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a \
+ --hash=sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027 \
+ --hash=sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7 \
+ --hash=sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518 \
+ --hash=sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf \
+ --hash=sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b \
+ --hash=sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9 \
+ --hash=sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544 \
+ --hash=sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da \
+ --hash=sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509 \
+ --hash=sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f \
+ --hash=sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a \
+ --hash=sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f
+ckcc-protocol==1.4.0 \
+ --hash=sha256:c5fcc4705b4b78ec515b39549642570a660142407fa684c278cb0aea8122defa \
+ --hash=sha256:cd93d4d3e3308ea4580aa6be5b4613a8266fd96b0cc1af51e7168def27bbece5
+cryptography==45.0.3 \
+ --hash=sha256:00094838ecc7c6594171e8c8a9166124c1197b074cfca23645cee573910d76bc \
+ --hash=sha256:050ce5209d5072472971e6efbfc8ec5a8f9a841de5a4db0ebd9c2e392cb81972 \
+ --hash=sha256:232954730c362638544758a8160c4ee1b832dc011d2c41a306ad8f7cccc5bb0b \
+ --hash=sha256:25286aacb947286620a31f78f2ed1a32cded7be5d8b729ba3fb2c988457639e4 \
+ --hash=sha256:2f8f8f0b73b885ddd7f3d8c2b2234a7d3ba49002b0223f58cfde1bedd9563c56 \
+ --hash=sha256:38deed72285c7ed699864f964a3f4cf11ab3fb38e8d39cfcd96710cd2b5bb716 \
+ --hash=sha256:3ad69eeb92a9de9421e1f6685e85a10fbcfb75c833b42cc9bc2ba9fb00da4710 \
+ --hash=sha256:5555365a50efe1f486eed6ac7062c33b97ccef409f5970a0b6f205a7cfab59c8 \
+ --hash=sha256:555e5e2d3a53b4fabeca32835878b2818b3f23966a4efb0d566689777c5a12c8 \
+ --hash=sha256:57a6500d459e8035e813bd8b51b671977fb149a8c95ed814989da682314d0782 \
+ --hash=sha256:5833bb4355cb377ebd880457663a972cd044e7f49585aee39245c0d592904578 \
+ --hash=sha256:71320fbefd05454ef2d457c481ba9a5b0e540f3753354fff6f780927c25d19b0 \
+ --hash=sha256:7573d9eebaeceeb55285205dbbb8753ac1e962af3d9640791d12b36864065e71 \
+ --hash=sha256:92d5f428c1a0439b2040435a1d6bc1b26ebf0af88b093c3628913dd464d13fa1 \
+ --hash=sha256:97787952246a77d77934d41b62fb1b6f3581d83f71b44796a4158d93b8f5c490 \
+ --hash=sha256:9bb5bf55dcb69f7067d80354d0a348368da907345a2c448b0babc4215ccd3497 \
+ --hash=sha256:9cc80ce69032ffa528b5e16d217fa4d8d4bb7d6ba8659c1b4d74a1b0f4235fca \
+ --hash=sha256:9e4253ed8f5948a3589b3caee7ad9a5bf218ffd16869c516535325fece163dcc \
+ --hash=sha256:9eda14f049d7f09c2e8fb411dda17dd6b16a3c76a1de5e249188a32aeb92de19 \
+ --hash=sha256:a2b56de3417fd5f48773ad8e91abaa700b678dc7fe1e0c757e1ae340779acf7b \
+ --hash=sha256:af3f92b1dc25621f5fad065288a44ac790c5798e986a34d393ab27d2b27fcff9 \
+ --hash=sha256:c5edcb90da1843df85292ef3a313513766a78fbbb83f584a5a58fb001a5a9d57 \
+ --hash=sha256:c824c9281cb628015bfc3c59335163d4ca0540d49de4582d6c2637312907e4b1 \
+ --hash=sha256:c92519d242703b675ccefd0f0562eb45e74d438e001f8ab52d628e885751fb06 \
+ --hash=sha256:ca932e11218bcc9ef812aa497cdf669484870ecbcf2d99b765d6c27a86000942 \
+ --hash=sha256:cb6ab89421bc90e0422aca911c69044c2912fc3debb19bb3c1bfe28ee3dff6ab \
+ --hash=sha256:cfd84777b4b6684955ce86156cfb5e08d75e80dc2585e10d69e47f014f0a5342 \
+ --hash=sha256:d377dde61c5d67eb4311eace661c3efda46c62113ff56bf05e2d679e02aebb5b \
+ --hash=sha256:d54ae41e6bd70ea23707843021c778f151ca258081586f0cfa31d936ae43d1b2 \
+ --hash=sha256:dc10ec1e9f21f33420cc05214989544727e776286c1c16697178978327b95c9c \
+ --hash=sha256:ec21313dd335c51d7877baf2972569f40a4291b76a0ce51391523ae358d05899 \
+ --hash=sha256:ec64ee375b5aaa354b2b273c921144a660a511f9df8785e6d1c942967106438e \
+ --hash=sha256:ed43d396f42028c1f47b5fec012e9e12631266e3825e95c00e3cf94d472dac49 \
+ --hash=sha256:edd6d51869beb7f0d472e902ef231a9b7689508e83880ea16ca3311a00bf5ce7 \
+ --hash=sha256:f22af3c78abfbc7cbcdf2c55d23c3e022e1a462ee2481011d518c7fb9c9f3d65 \
+ --hash=sha256:fae1e637f527750811588e4582988932c222f8251f7b7ea93739acb624e1487f \
+ --hash=sha256:fed5aaca1750e46db870874c9c273cd5182a9e9deb16f06f7bdffdb5c2bde4b9
+ecdsa==0.19.1 \
+ --hash=sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3 \
+ --hash=sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61
+hidapi==0.14.0.post4 \
+ --hash=sha256:01747e681d138ec614321ef6f069e5be3743fa210112e529a34d3e99635e4ac0 \
+ --hash=sha256:04357092b39631d8034b17fd111c5583be2790ad7979ac1983173344d28824e7 \
+ --hash=sha256:0d51f8102a2441ce22e080576f8f370d25cb3962161818a89f236b0401840f18 \
+ --hash=sha256:10a01af155c51a8089fe44e627af2fbd323cfbef7bd55a86837d971aef6088b0 \
+ --hash=sha256:129d684c2760fafee9014ce63a58d8e2699cdf00cd1a11bb3d706d4715f5ff96 \
+ --hash=sha256:1304fdeb694f581c46e7b0d6aebc6adfa66219177f04cacddbec0bd906bd5b7c \
+ --hash=sha256:142374bb39c8973c6d04a2b8b767d64891741d05b09364b32531d9389c3a15bb \
+ --hash=sha256:1487312ad50cf2c08a5ea786167b3229afd6478c4b26974157c3845a84e91231 \
+ --hash=sha256:1591e98c0e6db4cc1e34e96295b4ea68eaf37d365d664570441388869e8e3618 \
+ --hash=sha256:1807ff8abe3c5dcfa9d8acd71b1ab9f0aeb69cdbb039ddcbb150ed9fbbfd1ba7 \
+ --hash=sha256:1bee0f731874d78367a3bf131cb0325578bc9fee0678ed00c4ca3ded45d11c20 \
+ --hash=sha256:20a466e4cf2230687d21f55ffffb1a2384a2262fc343e507dd01d1ab981f7573 \
+ --hash=sha256:21627bb8a0e2023da1dfb7cb7b970c30d6a86e6498721f1123d018b2f64b426f \
+ --hash=sha256:21ebd1420db116733536fae227f1cb30ad74bded5090269cdda4facfa73a8867 \
+ --hash=sha256:293b207e737df4577d27661c5135e7c16976f706d3739d7a53a169dde1aaebaa \
+ --hash=sha256:2acadb4f1ae569c4f73ddb461af8733e8f5efcb290c3d0ef1b0671ba793b0ae3 \
+ --hash=sha256:2d1c102f754b2085b270e7c29cb8a148ffb05e10325c373d05ac16e2cbce131c \
+ --hash=sha256:3253d198b193065d633cde3f9a59dabeeb1608ece26f0f319a151e8c7775d7ae \
+ --hash=sha256:348e68e3a2145a6ec6bebce13ffdf3e5883d8c720752c365027f16e16764def6 \
+ --hash=sha256:380a74e743afe7a0241e0efce73ce9697f41d4e2e0a030be5458a44f9119427a \
+ --hash=sha256:4169893fe5e368777fce7575a8bdedc1861f13d8fb9fda6b05e8155dde6eb7f1 \
+ --hash=sha256:41d532d5a358a63db4d7fc1e57ea107150445c90167b39ba6f8fb84597396a48 \
+ --hash=sha256:48fce253e526d17b663fbf9989c71c7ef7653ced5f4be65f1437c313fb3dbdf6 \
+ --hash=sha256:4939faf6382d1c89462e72aa08636bbfe97ecb5464a34b14997e0ca3e1f92906 \
+ --hash=sha256:4f04de00e40db2efc0bcdd047c160274ba7ccd861100fd87c295dd63cb932f2f \
+ --hash=sha256:56d7538a4e156041bb80f07f47c327f8944e39da469b010041ce44e324d0657c \
+ --hash=sha256:58a0a0c029886de8b301ce1ee2e7fd6914ae1ca49feb37cc9930c26baa683427 \
+ --hash=sha256:5a5af70dad759b45536a9946d8232ef7d90859845d3554c93bea3e790250df75 \
+ --hash=sha256:5c14c54cbfd45553cd3e6a23014f8e8f2d12c41cd2783e84c2cb774976d4648f \
+ --hash=sha256:60115947607b8b0a719420726a541bad68728ece38b20654e81fef77c9e0bd2f \
+ --hash=sha256:6270677da02e86b56b81afd5f6f313736b8315b493f3c8a431da285e3a3c5de9 \
+ --hash=sha256:6439fc9686518d0336fac8c5e370093279f53c997540065fce131c97567118d8 \
+ --hash=sha256:68d7e9ba5c48e50f322057b9f88d799c105b5d46c966981aa8e5047b6091541f \
+ --hash=sha256:6b424ec16068d58d13fb67c7fb728824a3888f8f7fb6ffa3c82d5a54d8b74b7f \
+ --hash=sha256:6e08884ee9e1e3963701c1cdf22edd17c7ff708728f163efc396964460b3f9b4 \
+ --hash=sha256:6eaff1d120c47e1a121eada8dc85eac007d1ed81f3db7fc0da5b6ed17d8edefb \
+ --hash=sha256:6f96ae777e906f0a9d6f75e873313145dfec2b774f558bfcae8ba34f09792460 \
+ --hash=sha256:707b1ebf5cb051b020e94b039e603351bf2e6620b48fc970228e0dd5d3a91fca \
+ --hash=sha256:74ae8ce339655b2568d74e49c8ef644d34a445dd0a9b4b89d1bf09447b83f5af \
+ --hash=sha256:7d099c259aadcab2bc3f4fb5a1db579ec886c2cade7533016f62778235150746 \
+ --hash=sha256:80fa94668d21b12daf62b034f647d71236470a8ba9a7580e220c47e9c119d932 \
+ --hash=sha256:87218eeba366c871adcc273407aacbabab781d6a964919712d5583eded5ca50f \
+ --hash=sha256:884fa003d899113e14908bd3b519c60b48fc3cec0410264dcbdad1c4a8fc2e8d \
+ --hash=sha256:8a2d466b995f8ff387d68c052d3b74ee981a4ddc4f1a99f32f2dc7022273dc11 \
+ --hash=sha256:8d924bd002a1c17ca51905b3b7b3d580e80ec211a9b8fe4667b73db0ff9e9b54 \
+ --hash=sha256:8de94caca7f2616e41466c0ccdf7a96f567914e9e85e89e0b607018777fc0755 \
+ --hash=sha256:8e20d0a1298a4bd342d7d927d928f1a5a29e5fc9dbf9a79e95dc6e2d386d5070 \
+ --hash=sha256:949f437f517e81bc567429f41fb1e67349046eb43e52d47b2852b5847de452ee \
+ --hash=sha256:97192b7756dd854cb2ebc8a1862ffa009cdc203e0399777764462cae3c459d58 \
+ --hash=sha256:9e4b462fc1f2b160442618448132aebadb71c06b6eb7654eae4385c490100a67 \
+ --hash=sha256:9f14ac2737fd6f58d88d2e6bf8ebd03aac7b486c14d3f570b7b1d0013d61b726 \
+ --hash=sha256:a18af6ebd751eea7ddfb093ddf7d0371b05ba0f9a2f8593c7255a34e6bd753ff \
+ --hash=sha256:a28de4a03fc276614518d8d0997d8152d0edaf8ca9166522316ef1c455e8bc29 \
+ --hash=sha256:a2c4c3b3d77b759a4a118aa8428da1daf21c01b49915f44d7a3f198bcee4aa7b \
+ --hash=sha256:a90cfdd29c10425cd4e4cff34adb12d25048561fc946f3562679e45721060a1c \
+ --hash=sha256:ac3e6e794a0fd6ee4634bf1beea1c3c91ab6faf8b16f3f672a42541f9c5ea41f \
+ --hash=sha256:b6b9c4dbf7d7e2635ff129ce6ea82174865c073b75888b8b97dda5a3d9a70493 \
+ --hash=sha256:bca568a2b7d0d454c7921d70b1cc44f427eb6f95961b6d7b3b9b4532d0de74ef \
+ --hash=sha256:c45a493dffdfe614a9943a8c7f0df617254f836f1242478f7780fbeafb18a131 \
+ --hash=sha256:c8f722864a03c1d243a9538f0872e233d07fc3fe1d945c66c0cb632060d6d009 \
+ --hash=sha256:cb1a2b5da0dcfab6837281342d1785cc373484bd3f27bd06fd2211d88075a7bd \
+ --hash=sha256:d8ab5ba9fce95e342335ef48640221a46600c1afb66847432fad9823d40a2022 \
+ --hash=sha256:da700db947562f8c0ac530215b74b5a27e4c669916ec99cfb5accd14ba08562c \
+ --hash=sha256:da777638f5ecf9ef6c979f6c793417f54104d56ac99a48312d6f7e47858c2dd8 \
+ --hash=sha256:e11d475429a1bc943ceac4ad8da4be63b240e00da5e10863fc3cbd9a35fdb51c \
+ --hash=sha256:e1f6409854c0a8ed4d1fdbe88d5ee4baf6f19996d1561f76889a132cb083574d \
+ --hash=sha256:e70eab52781e58e819730d99e3c825e92c15ec2138b6902ed078c8cd73317ce0 \
+ --hash=sha256:e749b79d9cafc1e9fd9d397d8039377c928ca10a36847fda6407169513802f68 \
+ --hash=sha256:e9af3c9191b7a4dade9152454001622519f4ecfa674b78929b739cfbf4b35d51 \
+ --hash=sha256:f0cc21e82e95cb92ef951df8eb8acf5626ac8fa14ab5292abdab1b2349970445 \
+ --hash=sha256:f27c74deda0282a97dd0f006fd79d6d08fdb16c7a3ba156d52fce85e48515b0a \
+ --hash=sha256:f3ce310d366335e1ac9416d8e4a27d6eef2ae896fbee0135484d39d001711bea \
+ --hash=sha256:f67e60eaa287e0fa35223f2d1f9afda81dd7312c7ba07e08fbdaf1af8a923530 \
+ --hash=sha256:f787b76288450f60250895597dabb080894f0ea09ad5df0433412fee42452435 \
+ --hash=sha256:fa66391be8acb358b381c30f32be5880d591a3358e531d980832d593dfe83d5a \
+ --hash=sha256:fbd2835ff193d0261e0de375fea006cb7cb18a30ae1657af48a43e381f6a0995 \
+ --hash=sha256:fedb9c3be6a2376de436d13fcb37a686a9b6bc988585bcc4f5ec61cad925e794 \
+ --hash=sha256:ff021ed0962f2d5d67405ae53c85f6cb3ab8c5af3dff7db8c74672f79f7a39d1 \
+ --hash=sha256:ff67139fbaa91eed55e7e916bdc1ccdaf8c909a80a9c480011caa65c4ba82a97
+idna==3.10 \
+ --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \
+ --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3
+ledger-bitcoin==0.4.1 \
+ --hash=sha256:3cb4297ed7e557ef98349cdcbd667ef7368c047d6818c7cdcbca7af98b8006b6 \
+ --hash=sha256:b1fe1cdfd0f869f1e27968118832b11708904f71840b0a97219a0492ce1a7002
+ledgercomm==1.2.1 \
+ --hash=sha256:015cfc05f16b8c59f8cc1d9fc0b8935923f1fcc3806d33eeb6b0e055b44f5a91 \
+ --hash=sha256:8ffef5703355b8ec7b73bca325f70288f4d0dafcb299c09833de9c197fb6dd34
+libusb1==3.3.1 \
+ --hash=sha256:0ef69825173ce74af34444754c081cc324233edc6acc405658b3ad784833e076 \
+ --hash=sha256:3951d360f2daf0e0eacf839e15d2d1d2f4f5e7830231eb3188eeffef2dd17bad \
+ --hash=sha256:6e21b772d80d6487fbb55d3d2141218536db302da82f1983754e96c72781c102 \
+ --hash=sha256:808c9362299dcee01651aa87e71e9d681ccedb27fc4dbd70aaf14e245fb855f1
+mnemonic==0.21 \
+ --hash=sha256:1fe496356820984f45559b1540c80ff10de448368929b9c60a2b55744cc88acf \
+ --hash=sha256:72dc9de16ec5ef47287237b9b6943da11647a03fe7cf1f139fc3d7c4a7439288
+noiseprotocol==0.3.1 \
+ --hash=sha256:2e1a603a38439636cf0ffd8b3e8b12cee27d368a28b41be7dbe568b2abb23111 \
+ --hash=sha256:b092a871b60f6a8f07f17950dc9f7098c8fe7d715b049bd4c24ee3752b90d645
+packaging==25.0 \
+ --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \
+ --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f
+pip==25.1.1 \
+ --hash=sha256:2913a38a2abf4ea6b64ab507bd9e967f3b53dc1ede74b01b0931e1ce548751af \
+ --hash=sha256:3de45d411d308d5054c2168185d8da7f9a2cd753dbac8acbfa88a8909ecd9077
+protobuf==3.20.3 \
+ --hash=sha256:03038ac1cfbc41aa21f6afcbcd357281d7521b4157926f30ebecc8d4ea59dcb7 \
+ --hash=sha256:28545383d61f55b57cf4df63eebd9827754fd2dc25f80c5253f9184235db242c \
+ --hash=sha256:2e3427429c9cffebf259491be0af70189607f365c2f41c7c3764af6f337105f2 \
+ --hash=sha256:398a9e0c3eaceb34ec1aee71894ca3299605fa8e761544934378bbc6c97de23b \
+ --hash=sha256:44246bab5dd4b7fbd3c0c80b6f16686808fab0e4aca819ade6e8d294a29c7050 \
+ --hash=sha256:447d43819997825d4e71bf5769d869b968ce96848b6479397e29fc24c4a5dfe9 \
+ --hash=sha256:67a3598f0a2dcbc58d02dd1928544e7d88f764b47d4a286202913f0b2801c2e7 \
+ --hash=sha256:74480f79a023f90dc6e18febbf7b8bac7508420f2006fabd512013c0c238f454 \
+ --hash=sha256:819559cafa1a373b7096a482b504ae8a857c89593cf3a25af743ac9ecbd23480 \
+ --hash=sha256:899dc660cd599d7352d6f10d83c95df430a38b410c1b66b407a6b29265d66469 \
+ --hash=sha256:8c0c984a1b8fef4086329ff8dd19ac77576b384079247c770f29cc8ce3afa06c \
+ --hash=sha256:9aae4406ea63d825636cc11ffb34ad3379335803216ee3a856787bcf5ccc751e \
+ --hash=sha256:a7ca6d488aa8ff7f329d4c545b2dbad8ac31464f1d8b1c87ad1346717731e4db \
+ --hash=sha256:b6cc7ba72a8850621bfec987cb72623e703b7fe2b9127a161ce61e61558ad905 \
+ --hash=sha256:bf01b5720be110540be4286e791db73f84a2b721072a3711efff6c324cdf074b \
+ --hash=sha256:c02ce36ec760252242a33967d51c289fd0e1c0e6e5cc9397e2279177716add86 \
+ --hash=sha256:d9e4432ff660d67d775c66ac42a67cf2453c27cb4d738fc22cb53b5d84c135d4 \
+ --hash=sha256:daa564862dd0d39c00f8086f88700fdbe8bc717e993a21e90711acfed02f2402 \
+ --hash=sha256:de78575669dddf6099a8a0f46a27e82a1783c557ccc38ee620ed8cc96d3be7d7 \
+ --hash=sha256:e64857f395505ebf3d2569935506ae0dfc4a15cb80dc25261176c784662cdcc4 \
+ --hash=sha256:f4bd856d702e5b0d96a00ec6b307b0f51c1982c2bf9c0052cf9019e9a544ba99 \
+ --hash=sha256:f4c42102bc82a51108e449cbb32b19b180022941c727bac0cfd50170341f16ee
+pyaes==1.6.1 \
+ --hash=sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f
+pycparser==2.22 \
+ --hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \
+ --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc
+pyserial==3.5 \
+ --hash=sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb \
+ --hash=sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0
+requests==2.32.3 \
+ --hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \
+ --hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6
+safet==0.1.5 \
+ --hash=sha256:a7fd4b68bb1bc6185298af665c8e8e00e2bb2bcbddbb22844ead929b845c635e \
+ --hash=sha256:f966a23243312f64d14c7dfe02e8f13f6eeba4c3f51341f2c11ae57831f07de3
+semver==3.0.4 \
+ --hash=sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746 \
+ --hash=sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602
+setuptools==80.9.0 \
+ --hash=sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922 \
+ --hash=sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c
+shamir-mnemonic==0.3.0 \
+ --hash=sha256:188c6b5bd00d5e756e12e2b186c3cb7c98ff7ff44df608d4c1d2077f6b6e730f \
+ --hash=sha256:bc04886a1ddfe2a64d8a3ec51abf0f664d98d5b557cc7e78a8ad2d10a1d87438
+six==1.17.0 \
+ --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
+ --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
+slip10==1.0.1 \
+ --hash=sha256:02b350ae557b591791428b17551f95d7ac57e9211f37debdc814c90b4a123a54 \
+ --hash=sha256:4aa764369db0a261e468160ec1afeeb2b22d26392dd118c49b9daa91f642947b
+trezor==0.20.1 \
+ --hash=sha256:06f21ef1b0ad20f8bc220f229f2ff3abfedc15e90ca3bbdafcd967a6031e2cb3 \
+ --hash=sha256:6de50703102f90dc5399d40dd7c8134d13b6c54a617d41178b081baf2aeb2b91
+typing-extensions==4.13.2 \
+ --hash=sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c \
+ --hash=sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef
+urllib3==1.26.20 \
+ --hash=sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e \
+ --hash=sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32
+wheel==0.45.1 \
+ --hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729 \
+ --hash=sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248
\ No newline at end of file
diff --git a/contrib/deterministic-build/requirements.txt b/contrib/deterministic-build/requirements.txt
new file mode 100644
index 000000000000..21b9a465c258
--- /dev/null
+++ b/contrib/deterministic-build/requirements.txt
@@ -0,0 +1,56 @@
+aiohappyeyeballs==2.6.1 \
+ --hash=sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558
+aiohttp==3.12.9 \
+ --hash=sha256:2c9914c8914ff40b68c6e4ed5da33e88d4e8f368fddd03ceb0eb3175905ca782
+aiohttp-socks==0.10.1 \
+ --hash=sha256:49f2e1f8051f2885719beb1b77e312b5a27c3e4b60f0b045a388f194d995e068
+aiorpcX==0.25.0 \
+ --hash=sha256:940fa250ea5e9fd372d4c6acdc20dcb603bd1960ca324759d29864a4aaf64570
+aiosignal==1.3.2 \
+ --hash=sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54
+async-timeout==5.0.1 \
+ --hash=sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3
+attrs==22.2.0 \
+ --hash=sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99
+certifi==2025.4.26 \
+ --hash=sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6
+dnspython==2.4.2 \
+ --hash=sha256:8dcfae8c7460a2f84b4072e26f1c9f4101ca20c071649cb7c34e8b6a93d58984
+electrum-aionostr==0.1.0 \
+ --hash=sha256:3774f8e8312388272e10851a869c9f4d3d4a54d8d564851c36e2dc40297bec84
+electrum-ecc==0.0.7 \
+ --hash=sha256:ed4134e1dbff0fd83022764c6acc97a02cde3512927d7c41f4d48b9a06e91fb2
+frozenlist==1.6.0 \
+ --hash=sha256:b99655c32c1c8e06d111e7f41c06c29a5318cb1835df23a45518e02a47c63b68
+idna==3.10 \
+ --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9
+jsonpatch==1.33 \
+ --hash=sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c
+jsonpointer==3.0.0 \
+ --hash=sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef
+multidict==6.4.4 \
+ --hash=sha256:69ee9e6ba214b5245031b76233dd95408a0fd57fdb019ddcc1ead4790932a8e8
+packaging==25.0 \
+ --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f
+pip==25.1.1 \
+ --hash=sha256:3de45d411d308d5054c2168185d8da7f9a2cd753dbac8acbfa88a8909ecd9077
+propcache==0.3.1 \
+ --hash=sha256:40d980c33765359098837527e18eddefc9a24cea5b45e078a7f3bb5b032c6ecf
+protobuf==3.20.3 \
+ --hash=sha256:2e3427429c9cffebf259491be0af70189607f365c2f41c7c3764af6f337105f2
+python-socks==2.7.1 \
+ --hash=sha256:f1a0bb603830fe81e332442eada96757b8f8dec02bd22d1d6f5c99a79704c550
+QDarkStyle==3.2.3 \
+ --hash=sha256:0c0b7f74a6e92121008992b369bab60468157db1c02cd30d64a5e9a3b402f1ae
+qrcode==8.2 \
+ --hash=sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c
+QtPy==2.4.3 \
+ --hash=sha256:db744f7832e6d3da90568ba6ccbca3ee2b3b4a890c3d6fbbc63142f6e4cdf5bb
+setuptools==80.9.0 \
+ --hash=sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c
+typing-extensions==4.13.2 \
+ --hash=sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef
+wheel==0.45.1 \
+ --hash=sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729
+yarl==1.20.0 \
+ --hash=sha256:686d51e51ee5dfe62dec86e4866ee0e9ed66df700d55c828a615640adc885307
\ No newline at end of file
diff --git a/contrib/docker_notes.md b/contrib/docker_notes.md
new file mode 100644
index 000000000000..68ab30a6b715
--- /dev/null
+++ b/contrib/docker_notes.md
@@ -0,0 +1,68 @@
+# Using the build scripts
+
+Most of our build scripts are docker-based.
+(All, except the macOS build, which is a separate beast and always has to be special-cased
+at the cost of significant maintenance burden...)
+
+Typically, the build flow is:
+
+- build a docker image, based on debian
+ - the apt sources mirror used is `snapshot.debian.org`
+ - (except for the source tarball build, which is simple enough not to need this)
+ - this helps with historical reproducibility
+ - note that `snapshot.debian.org` is often slow and sometimes keeps timing out :/
+ (see #8496)
+ - a potential alternative would be `snapshot.notset.fr`, but that mirror is missing
+ e.g. `binary-i386`, which is needed for the wine/windows build.
+ - if you are just trying to build for yourself and don't need reproducibility,
+ you can just switch back to the default debian apt sources mirror.
+ - docker caches the build (locally), and so this step only needs to be rerun
+ if we update the Dockerfile. This caching happens automatically and by default.
+ - you can disable the caching by setting envvar `ELECBUILD_NOCACHE=1`. See below.
+- create a docker container from the image, and build the final binary inside the container
+
+
+## Notes about using Docker
+
+- To install Docker:
+
+ This assumes an Ubuntu (x86_64) host, but it should not be too hard to adapt to another similar system.
+
+ ```
+ $ 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
+ ```
+
+- To communicate with the docker daemon, the build scripts either need to be called via sudo,
+ or the unix user on the host system (e.g. the user you run as) needs to be
+ part of the `docker` group. i.e.:
+ ```
+ $ sudo usermod -aG docker ${USER}
+ ```
+ (and then reboot or similar for it to take effect)
+
+
+## Environment variables
+
+- `ELECBUILD_COMMIT`
+
+ When unset or empty, we build directly from the local git clone. These builds
+ are *not* reproducible.
+
+ When non-empty, it should be set to a git ref. We will create a fresh git clone
+ checked out at that reference in `/tmp/electrum_build/`, and build there.
+
+- `ELECBUILD_NOCACHE=1`
+
+ A non-empty value forces a rebuild of the docker image.
+
+ Before we started using `snapshot.debian.org` for apt sources,
+ setting this was necessary to properly test historical reproducibility.
+ (we were version-pinning packages installed using `apt`, but it was not realistic to
+ version-pin all transitive dependencies, and sometimes an update of those resulted in
+ changes to our binary builds)
+
+ I think setting this is no longer necessary for building reproducibly.
+
diff --git a/contrib/freeze_containers_distro.sh b/contrib/freeze_containers_distro.sh
new file mode 100755
index 000000000000..bc60ae7d6a55
--- /dev/null
+++ b/contrib/freeze_containers_distro.sh
@@ -0,0 +1,46 @@
+#!/bin/sh
+
+# Run this after a new release to update pin for build container distro packages
+
+set -e
+
+DEBIAN_SNAPSHOT_BASE="https://snapshot.debian.org/archive/debian/"
+DEBIAN_APPIMAGE_DISTRO="bullseye" # should match build-linux/appimage Dockerfile base
+DEBIAN_WINE_DISTRO="trixie" # should match build-wine Dockerfile base
+DEBIAN_ANDROID_DISTRO="trixie" # should match android Dockerfile base
+
+contrib="$(dirname "$0")"
+
+
+if [ ! -x /bin/wget ]; then
+ echo "no wget"
+ exit 1
+fi
+
+DEBIAN_SNAPSHOT_LATEST=$(wget -O- "${DEBIAN_SNAPSHOT_BASE}$(date +"?year=%Y&month=%m")" 2>/dev/null | grep "^/dev/null
+
+echo "Valid!"
+
+# build-linux
+echo "deb ${DEBIAN_SNAPSHOT} ${DEBIAN_APPIMAGE_DISTRO} main" > "$contrib/build-linux/appimage/apt.sources.list"
+echo "deb-src ${DEBIAN_SNAPSHOT} ${DEBIAN_APPIMAGE_DISTRO} main" >> "$contrib/build-linux/appimage/apt.sources.list"
+
+# build-wine
+echo "deb ${DEBIAN_SNAPSHOT} ${DEBIAN_WINE_DISTRO} main" > "$contrib/build-wine/apt.sources.list"
+echo "deb-src ${DEBIAN_SNAPSHOT} ${DEBIAN_WINE_DISTRO} main" >> "$contrib/build-wine/apt.sources.list"
+
+# android
+echo "deb ${DEBIAN_SNAPSHOT} ${DEBIAN_ANDROID_DISTRO} main" > "$contrib/android/apt.sources.list"
+echo "deb-src ${DEBIAN_SNAPSHOT} ${DEBIAN_ANDROID_DISTRO} main" >> "$contrib/android/apt.sources.list"
+
+echo "updated APT sources to ${DEBIAN_SNAPSHOT}"
diff --git a/contrib/freeze_packages.sh b/contrib/freeze_packages.sh
index 139fb6ee7887..93e0a25db0a9 100755
--- a/contrib/freeze_packages.sh
+++ b/contrib/freeze_packages.sh
@@ -1,22 +1,84 @@
#!/bin/bash
# Run this after a new release to update dependencies
+set -e
+
venv_dir=~/.electrum-venv
-contrib=$(dirname "$0")
+contrib="$(dirname "$0")"
+
+# note: we should not use a higher version of python than what the binaries bundle
+if [[ ! "$SYSTEM_PYTHON" ]] ; then
+ SYSTEM_PYTHON=$(which python3.10) || 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
+
+"${SYSTEM_PYTHON}" -m hashin -h > /dev/null 2>&1 || { "${SYSTEM_PYTHON}" -m pip install hashin; }
+
+for suffix in '' '-hw' '-binaries' '-binaries-mac' '-build-wine' '-build-mac' '-build-base' '-build-appimage' '-build-android'; do
+ reqfile="requirements${suffix}.txt"
+
+ rm -rf "$venv_dir"
+ "${SYSTEM_PYTHON}" -m venv "$venv_dir"
+
+ source "$venv_dir/bin/activate"
+
+ echo "Installing dependencies... (${reqfile})"
+
+ # We pin all python packaging tools (pip and friends). Some of our dependencies might
+ # pull some of them in (e.g. protobuf->setuptools), and all transitive dependencies
+ # must be pinned, so we might as well pin all packaging tools. This however means
+ # that we should explicitly install them now, so that we pin latest versions if possible.
+ python -m pip install --upgrade pip setuptools wheel
+
+ # install ghost packages to satisfy dependency resolvers
+ for package in $(cat "$contrib/requirements/ghost.txt"); do
+ python "$contrib/install_ghost.py" "$package"
+ done
+
+ python -m pip install -r "$contrib/requirements/${reqfile}" --upgrade
+
+ echo "OK."
+
+ requirements=$(pip freeze --all)
-which virtualenv > /dev/null 2>&1 || { echo "Please install virtualenv" && exit 1; }
+ restricted=$(echo $requirements | python "$contrib/deterministic-build/find_restricted_dependencies.py")
+ if [ ! -z "$restricted" ]; then
+ python -m pip install $restricted
+ fi
-rm $venv_dir -rf
-virtualenv $venv_dir
+ echo "Generating package hashes... (${reqfile})"
+ rm -f "$contrib/deterministic-build/${reqfile}"
+ touch "$contrib/deterministic-build/${reqfile}"
-source $venv_dir/bin/activate
+ # restrict ourselves to source-only packages.
+ # TODO expand this to all reqfiles...
+ HASHIN_FLAGS=""
+ if [[
+ "${suffix}" == "" ||
+ "${suffix}" == "-build-wine" ||
+ "${suffix}" == "-build-mac" ||
+ "${suffix}" == "-build-appimage" ||
+ "${suffix}" == "-build-android" ||
+ "0" == "1"
+ ]] ;
+ then
+ HASHIN_FLAGS="--python-version source"
+ fi
-echo "Installing dependencies"
+ # remove ghost packages before hashin step, so that they aren't hashed
+ for package in $(cat "$contrib/requirements/ghost.txt"); do
+ python -m pip uninstall -y $package
+ done
+ requirements=$(pip freeze --all)
-pushd $contrib/..
-python setup.py install
-popd
+ echo -e "\r Hashing requirements for $reqfile..."
+ ${SYSTEM_PYTHON} -m hashin $HASHIN_FLAGS -r "$contrib/deterministic-build/${reqfile}" $requirements
-pip freeze | sed '/^Electrum/ d' > $contrib/requirements.txt
+ echo "OK."
+done
-echo "Updated requirements"
+echo "Done. Updated requirements"
diff --git a/contrib/install_ghost.py b/contrib/install_ghost.py
new file mode 100644
index 000000000000..f6c978cfa10d
--- /dev/null
+++ b/contrib/install_ghost.py
@@ -0,0 +1,32 @@
+import sys, tempfile, subprocess
+from pathlib import Path
+from importlib.metadata import distribution
+
+
+PYPROJECT_TOML = """
+[project]
+name = "{name}"
+version = "{version}"
+description = "Ghost package to satisfy dependencies"
+"""
+
+
+def install_ghost(name: str, version: str) -> None:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ pyproject_toml = PYPROJECT_TOML.format(name=name, version=version)
+ (Path(tmpdir) / "pyproject.toml").write_text(pyproject_toml)
+ subprocess.check_call([sys.executable, "-m", "pip", "install", tmpdir])
+
+ dist = distribution(name)
+ for file in dist.files:
+ path = file.locate()
+ if path.name == "direct_url.json":
+ path.unlink()
+
+
+if __name__ == "__main__":
+ if len(sys.argv) != 2:
+ print("Usage: python install_ghost.py ==")
+ sys.exit(1)
+ name, version = sys.argv[1].split("==")
+ install_ghost(name, version)
diff --git a/contrib/locale/build_cleanlocale.sh b/contrib/locale/build_cleanlocale.sh
new file mode 100755
index 000000000000..3d87e88a6172
--- /dev/null
+++ b/contrib/locale/build_cleanlocale.sh
@@ -0,0 +1,17 @@
+#!/bin/bash
+
+set -e
+
+CONTRIB_LOCALE="$(dirname "$(realpath "$0" 2> /dev/null || grealpath "$0")")"
+CONTRIB="$CONTRIB_LOCALE"/..
+PROJECT_ROOT="$CONTRIB"/..
+
+cd "$PROJECT_ROOT"
+git submodule update --init
+
+LOCALE="$PROJECT_ROOT/electrum/locale/"
+cd "$LOCALE"
+git clean -ffxd
+git reset --hard
+rm -rf llm_proofreader
+"$CONTRIB_LOCALE/build_locale.sh" "$LOCALE/locale" "$LOCALE/locale"
diff --git a/contrib/locale/build_locale.sh b/contrib/locale/build_locale.sh
new file mode 100755
index 000000000000..cdd72becf79c
--- /dev/null
+++ b/contrib/locale/build_locale.sh
@@ -0,0 +1,36 @@
+#!/bin/bash
+#
+# This script converts human-readable (.po) locale files to compiled (.mo) locale files.
+
+set -e
+
+CONTRIB_LOCALE="$(dirname "$(realpath "$0" 2> /dev/null || grealpath "$0")")"
+
+
+if [[ ! -d "$1" || -z "$2" ]]; then
+ echo "usage: $0 locale_source_dir locale_dest_dir"
+ echo " The dirs can match, to build in place."
+ # ^ note: these are the paths to the "inner" locale/ dir
+ exit 1
+fi
+
+# convert $1 and $2 to abs paths
+SRC_DIR="$(realpath "$1" 2> /dev/null || grealpath "$1")"
+DST_DIR="$(realpath "$2" 2> /dev/null || grealpath "$2")"
+
+if ! which msgfmt > /dev/null 2>&1; then
+ echo "Please install gettext"
+ exit 1
+fi
+
+cd "$SRC_DIR"
+mkdir -p "$DST_DIR"
+
+for i in *; do
+ dir="$DST_DIR/$i/LC_MESSAGES"
+ mkdir -p "$dir"
+ (msgfmt --output-file="$dir/electrum.mo" "$i/electrum.po" || true)
+done
+
+echo "running stats.py"
+"$CONTRIB_LOCALE/stats.py"
diff --git a/contrib/locale/push_locale.py b/contrib/locale/push_locale.py
new file mode 100755
index 000000000000..be2849ee2c92
--- /dev/null
+++ b/contrib/locale/push_locale.py
@@ -0,0 +1,160 @@
+#!/usr/bin/env python3
+#
+# This script extracts "raw" strings from the codebase,
+# and uploads them to crowdin, for the community to translate them.
+#
+# Dependencies:
+# $ sudo apt-get install python3-requests gettext qt6-l10n-tools
+
+import glob
+import os
+import subprocess
+import sys
+
+try:
+ import requests
+except ImportError as e:
+ sys.exit(f"Error: {str(e)}. Try 'python3 -m pip install --user '")
+
+# set cwd
+project_root = os.path.abspath(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
+os.chdir(project_root)
+
+locale_dir = os.path.join(project_root, "electrum", "locale")
+if not os.path.exists(os.path.join(locale_dir, "locale")):
+ raise Exception(f"missing git submodule for locale? {locale_dir}")
+
+# check dependencies are available
+try:
+ subprocess.check_output(["xgettext", "--version"])
+ subprocess.check_output(["msgcat", "--version"])
+except (subprocess.CalledProcessError, OSError) as e2:
+ raise Exception("missing gettext. Maybe try 'apt install gettext'")
+
+QT_LUPDATE="lupdate"
+QT_LCONVERT="lconvert"
+try:
+ subprocess.check_output([QT_LUPDATE, "-version"])
+ subprocess.check_output([QT_LCONVERT, "-h"])
+except (subprocess.CalledProcessError, OSError) as e1:
+ QT_LUPDATE="/usr/lib/qt6/bin/lupdate" # workaround qt5/qt6 confusion on ubuntu 22.04
+ QT_LCONVERT="/usr/lib/qt6/bin/lconvert"
+ try:
+ subprocess.check_output([QT_LUPDATE, "-version"])
+ subprocess.check_output([QT_LCONVERT, "-h"])
+ except (subprocess.CalledProcessError, OSError) as e2:
+ raise Exception("missing Qt lupdate/convert tools. Maybe try 'apt install qt6-l10n-tools'")
+
+# create build dir
+build_dir = os.path.join(locale_dir, "build")
+if not os.path.exists(build_dir):
+ os.mkdir(build_dir)
+
+# add .py files
+files_list = glob.glob("electrum/**/*.py", recursive=True)
+files_list = sorted(files_list) # makes output deterministic across CI runs
+with open(f"{build_dir}/app.fil", "w", encoding="utf-8") as f:
+ for item in files_list:
+ f.write(item + "\n")
+print("Found {} .py files to translate".format(len(files_list)))
+
+# Generate fresh translation template
+print('Generating template...')
+# note: do not use xgettext option "--sort-output", as that makes human translators have to context-switch all the time
+cmd = ["xgettext", "--from-code", "UTF-8", "--language", "Python", "--no-wrap", "-f", f"{build_dir}/app.fil", f"--output={build_dir}/messages_gettext.pot"]
+subprocess.check_output(cmd)
+
+# add QML translations
+files_list = glob.glob("electrum/gui/qml/**/*.qml", recursive=True)
+files_list = sorted(files_list) # makes output deterministic across CI runs
+with open(f"{build_dir}/qml.lst", "w", encoding="utf-8") as f:
+ for item in files_list:
+ f.write(item + "\n")
+print("Found {} QML files to translate".format(len(files_list)))
+
+# note: lupdate writes relative paths into its output .ts file, relative to the .ts file itself :/
+cmd = [QT_LUPDATE, f"@{build_dir}/qml.lst","-ts", f"{build_dir}/qml.ts"]
+print('Collecting strings')
+subprocess.check_output(cmd)
+
+cmd = [QT_LCONVERT, "-of", "po", "-o", f"{build_dir}/messages_qml.pot", f"{build_dir}/qml.ts"]
+print('Convert to gettext')
+subprocess.check_output(cmd)
+
+print("Fixing some paths in messages_qml.pot")
+# sed from " ../../gui/qml/"
+# to " electrum/gui/qml/"
+cmd = ["sed", "-i", r"s/ ..\/..\/gui\/qml\// electrum\/gui\/qml\//g", f"{build_dir}/messages_qml.pot"]
+subprocess.check_output(cmd)
+
+cmd = ["msgcat", "-u", "-o", f"{build_dir}/messages.pot", f"{build_dir}/messages_gettext.pot", f"{build_dir}/messages_qml.pot"]
+print('Generate template')
+subprocess.check_output(cmd)
+
+# Add a custom PO header entry to messages.pot. This header survives crowdin,
+# and will still be in the translated .po files, and will get compiled into the final .mo files.
+cnt_src_strings = 0
+with open(f"{build_dir}/messages.pot", "r", encoding="utf-8") as f:
+ for line in f.readlines():
+ if line.startswith('msgid '):
+ cnt_src_strings += 1
+with open(f"{build_dir}/messages_customheader.pot", "w", encoding="utf-8") as f:
+ f.write('''msgid ""\n''')
+ f.write('''msgstr ""\n''')
+ f.write(f'''"X-Electrum-SourceStringCount: {cnt_src_strings}"\n''')
+cmd = ["msgcat", "-u", "-o", f"{build_dir}/messages.pot", f"{build_dir}/messages.pot", f"{build_dir}/messages_customheader.pot"]
+print('Add custom header to template')
+subprocess.check_output(cmd)
+
+# prepare uploading to crowdin
+os.chdir(os.path.join(project_root, "electrum"))
+
+crowdin_api_key = None
+filename = os.path.expanduser('~/.crowdin_api_key')
+if os.path.exists(filename):
+ with open(filename) as f:
+ crowdin_api_key = f.read().strip()
+if "crowdin_api_key" in os.environ:
+ crowdin_api_key = os.environ["crowdin_api_key"]
+if not crowdin_api_key:
+ print('Missing crowdin_api_key. Cannot push.')
+ sys.exit(1)
+print('Found crowdin_api_key. Will push updated source-strings to crowdin.')
+
+crowdin_project_id = 20482 # for "Electrum" project on crowdin
+locale_file_name = os.path.join(build_dir, "messages.pot")
+crowdin_file_name = "messages.pot"
+crowdin_file_id = 68 # for "/electrum-client/messages.pot"
+global_headers = {"Authorization": "Bearer {}".format(crowdin_api_key)}
+
+# client.storages.add_storage(f)
+# https://support.crowdin.com/developer/api/v2/?q=api#tag/Storage/operation/api.storages.post
+print(f"Uploading to temp storage...")
+url = f'https://api.crowdin.com/api/v2/storages'
+with open(locale_file_name, 'rb') as f:
+ headers = {**global_headers, **{"Crowdin-API-FileName": crowdin_file_name}}
+ response = requests.request("POST", url, data=f, headers=headers)
+ response.raise_for_status()
+ print("", "storages.add_storage:", "-" * 20, response.text, "-" * 20, sep="\n")
+ storage_id = response.json()["data"]["id"]
+
+# client.source_files.update_file(projectId=crowdin_project_id, storageId=storage_id, fileId=crowdin_file_id)
+# https://support.crowdin.com/developer/api/v2/?q=api#tag/Source-Files/operation/api.projects.files.put
+print(f"Copying from temp storage and updating file in perm storage...")
+url = f'https://api.crowdin.com/api/v2/projects/{crowdin_project_id}/files/{crowdin_file_id}'
+headers = {**global_headers, **{"content-type": "application/json"}}
+response = requests.request("PUT", url, json={"storageId": storage_id}, headers=headers)
+response.raise_for_status()
+print("", "source_files.update_file:", "-" * 20, response.text, "-" * 20, sep="\n")
+
+# client.translations.build_crowdin_project_translation(projectId=crowdin_project_id)
+# https://support.crowdin.com/developer/api/v2/?q=api#tag/Translations/operation/api.projects.translations.builds.post
+print(f"Rebuilding translations...")
+url = f'https://api.crowdin.com/api/v2/projects/{crowdin_project_id}/translations/builds'
+headers = {**global_headers, **{"content-type": "application/json"}}
+json_data = {
+ #"exportApprovedOnly": True, # only include translated-strings approved by users with "Proofreader" permission
+} # note: these settings MUST be verified by electrum-locale/update.py again, at download-time.
+response = requests.request("POST", url, json=json_data, headers=headers)
+response.raise_for_status()
+print("", "translations.build_crowdin_project_translation:", "-" * 20, response.text, "-" * 20, sep="\n")
diff --git a/contrib/locale/stats.py b/contrib/locale/stats.py
new file mode 100755
index 000000000000..072e6c2684bc
--- /dev/null
+++ b/contrib/locale/stats.py
@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2026 The Electrum developers
+# Distributed under the MIT software license, see the accompanying
+# file LICENCE or http://www.opensource.org/licenses/mit-license.php
+#
+#
+# This generates a 'stats.json' file containing some statistics about translation completeness.
+
+import gettext
+import glob
+import json
+import os
+
+PROJECT_ROOT = os.path.abspath(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
+LOCALE_DIR = os.path.join(PROJECT_ROOT, "electrum", "locale", "locale")
+
+
+if __name__ == '__main__':
+ catalog_size = {} # type: dict[str, int]
+ source_string_count = None
+ # - calc stats
+ files_list = glob.glob(f"{LOCALE_DIR}/*/LC_MESSAGES/*.mo")
+ for fname in files_list:
+ lang_code = os.path.basename(os.path.dirname(os.path.dirname(fname)))
+ try:
+ t = gettext.translation('electrum', LOCALE_DIR, languages=[lang_code])
+ except OSError as e:
+ raise Exception(f"cannot find or parse .mo file matching {fname!r}") from e
+ # calc catalog size of translated strings
+ catalog_size[lang_code] = len(t._catalog)
+ # same SourceStringCount header should be present in all .mo files:
+ t_info = t.info()
+ try:
+ ss_cnt = int(t_info["x-electrum-sourcestringcount"])
+ except Exception as e:
+ raise Exception(
+ f"missing or malformed 'x-electrum-sourcestringcount' header, for {lang_code!r}.\n"
+ f"found {t_info}"
+ ) from e
+ if source_string_count is None:
+ source_string_count = ss_cnt
+ elif source_string_count != ss_cnt:
+ raise Exception(
+ f"inconsistent 'x-electrum-sourcestringcount' headers! "
+ f"prev_cnt={source_string_count}, new_cnt={ss_cnt} (for lang={lang_code})")
+ # - convert to json data. example:
+ # {
+ # "source_string_count": 9999,
+ # "translations": {
+ # "de_DE": {
+ # "string_count": 400,
+ # },
+ # ...
+ # }
+ # }
+ json_data = {
+ "source_string_count": source_string_count,
+ "translations": {},
+ }
+ for lang_code in catalog_size:
+ json_data["translations"][lang_code] = {}
+ json_data["translations"][lang_code]["string_count"] = catalog_size[lang_code]
+ # - write json to disk
+ with open(f"{LOCALE_DIR}/stats.json", "w", encoding="utf-8") as f:
+ json_str = json.dumps(
+ json_data,
+ indent=4,
+ sort_keys=True
+ )
+ f.write(json_str)
+ print(f"done. created file '{LOCALE_DIR}/stats.json'")
diff --git a/contrib/make_apk b/contrib/make_apk
deleted file mode 100755
index 8cb07491c171..000000000000
--- a/contrib/make_apk
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/bin/bash
-pushd lib
-VERSION=$(python -c "import version; print version.ELECTRUM_VERSION")".0"
-popd
-echo $VERSION
-echo $VERSION > contrib/apk_version
-pushd ./gui/kivy/; make apk; popd
diff --git a/contrib/make_download b/contrib/make_download
index 84e4ff21b6ca..deb515c4533e 100755
--- a/contrib/make_download
+++ b/contrib/make_download
@@ -1,51 +1,95 @@
-#!/usr/bin/python2
+#!/usr/bin/python3
import re
import os
+import sys
+import importlib
+from collections import defaultdict
-from versions import version, version_win, version_mac, version_android, version_apk
-from versions import download_template, download_page
+
+if len(sys.argv) < 2:
+ print(f"ERROR. usage: {os.path.basename(__file__)} ", file=sys.stderr)
+ sys.exit(1)
+
+# cd to project root
+os.chdir(os.path.dirname(os.path.dirname(__file__)))
+
+# load version.py; needlessly complicated alternative to "imp.load_source":
+version_spec = importlib.util.spec_from_file_location('version', 'electrum/version.py')
+version_module = importlib.util.module_from_spec(version_spec)
+version_spec.loader.exec_module(version_module)
+
+ELECTRUM_VERSION = version_module.ELECTRUM_VERSION
+print(f"version: {ELECTRUM_VERSION}", file=sys.stderr)
+
+dirname = sys.argv[1]
+
+print(f"directory: {dirname}", file=sys.stderr)
+
+download_page = os.path.join(dirname, "panel-download.html")
+download_template = download_page + ".template"
with open(download_template) as f:
- string = f.read()
+ download_page_str = f.read()
-string = string.replace("##VERSION##", version)
-string = string.replace("##VERSION_WIN##", version_win)
-string = string.replace("##VERSION_MAC##", version_mac)
-string = string.replace("##VERSION_ANDROID##", version_android)
-string = string.replace("##VERSION_APK##", version_apk)
+version = version_win = version_mac = version_android = ELECTRUM_VERSION
+download_page_str = download_page_str.replace("##VERSION##", version)
+download_page_str = download_page_str.replace("##VERSION_WIN##", version_win)
+download_page_str = download_page_str.replace("##VERSION_MAC##", version_mac)
+download_page_str = download_page_str.replace("##VERSION_ANDROID##", version_android)
+download_page_str = download_page_str.replace("##VERSION_APK##", version_android)
+# note: all dist files need to be listed here that we expect sigs for,
+# even if they are not linked to from the website
files = {
- 'tgz': "Electrum-%s.tar.gz" % version,
- 'zip': "Electrum-%s.zip" % version,
- 'mac': "electrum-%s.dmg" % version_mac,
- 'win': "electrum-%s.exe" % version_win,
- 'win_setup': "electrum-%s-setup.exe" % version_win,
- 'win_portable': "electrum-%s-portable.exe" % version_win,
+ "tgz": f"Electrum-{version}.tar.gz",
+ "tgz_srconly": f"Electrum-sourceonly-{version}.tar.gz",
+ "appimage": f"electrum-{version}-x86_64.AppImage",
+ "mac": f"electrum-{version_mac}.dmg",
+ "win": f"electrum-{version_win}.exe",
+ "win_setup": f"electrum-{version_win}-setup.exe",
+ "win_portable": f"electrum-{version_win}-portable.exe",
+ "apk_arm64": f"Electrum-{version_android}-arm64-v8a-release.apk",
+ "apk_armeabi": f"Electrum-{version_android}-armeabi-v7a-release.apk",
+ "apk_x86_64": f"Electrum-{version_android}-x86_64-release.apk",
}
-for k, n in files.items():
- path = "dist/%s"%n
- link = "https://download.electrum.org/%s/%s"%(version,n)
- if not os.path.exists(path):
- os.system("wget -q %s -O %s" % (link, path))
- if not os.path.getsize(path):
- os.unlink(path)
- string = re.sub("
"%k, '', string, flags=re.DOTALL + re.MULTILINE)
- continue
- if os.system("gpg --verify %s"%sigpath) != 0:
- raise BaseException(sigpath)
- string = string.replace("##link_%s##"%k, link)
-
-
-with open(download_page,'w') as f:
- f.write(string)
+# default signers
+signers = ['ThomasV', 'sombernight_releasekey']
+
+# detect extra signers
+list_dir = sorted(os.listdir('dist'))
+detected_sigs = defaultdict(set)
+for f in list_dir:
+ if f.endswith('.asc'):
+ parts = f.split('.')
+ signer = parts[-2]
+ filename = '.'.join(parts[0:-2])
+ detected_sigs[signer].add(filename)
+for k, v in detected_sigs.items():
+ if v == set(files.values()):
+ if k not in signers:
+ signers.append(k)
+
+print(f"signers: {signers}", file=sys.stderr)
+
+friendly_nick = lambda x: 'SomberNight' if x=='sombernight_releasekey' else x
+signers_list = ', '.join("%s"%(x, friendly_nick(x)) for x in signers)
+download_page_str = download_page_str.replace("##signers_list##", signers_list)
+
+for k, filename in files.items():
+ path = "dist/%s"%filename
+ assert filename in list_dir
+ link = "https://download.electrum.org/%s/%s"%(version, filename)
+ download_page_str = download_page_str.replace("##link_%s##" % k, link)
+ download_page_str = download_page_str.replace("##sigs_%s##" % k, link + '.asc')
+
+
+# download page has been constructed from template; now insert it into index.html
+index_html_path = os.path.join(dirname, "index.html")
+with open(f"{index_html_path}.template") as f:
+ index_html_str = f.read()
+index_html_str = index_html_str.replace("##DOWNLOAD_PAGE##", download_page_str)
+with open(index_html_path, 'w') as f:
+ f.write(index_html_str)
diff --git a/contrib/make_libsecp256k1.sh b/contrib/make_libsecp256k1.sh
new file mode 100755
index 000000000000..40326d9c081a
--- /dev/null
+++ b/contrib/make_libsecp256k1.sh
@@ -0,0 +1,76 @@
+#!/bin/bash
+
+# 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
+#
+# To cross-compile to Linux x86:
+# sudo apt-get install gcc-multilib g++-multilib
+# $ AUTOCONF_FLAGS="--host=i686-linux-gnu CFLAGS=-m32 CXXFLAGS=-m32 LDFLAGS=-m32" ./contrib/make_libsecp256k1.sh
+
+LIBSECP_VERSION="1a53f4961f337b4d166c25fce72ef0dc88806618"
+# ^ tag "v0.7.1"
+# note: this version is duplicated in contrib/android/p4a_recipes/libsecp256k1/__init__.py
+# (and also in electrum-ecc, for the "secp256k1" git submodule)
+
+set -e
+
+. "$(dirname "$0")/build_tools_util.sh" || (echo "Could not source build_tools_util.sh" && exit 1)
+
+here="$(dirname "$(realpath "$0" 2> /dev/null || grealpath "$0")")"
+CONTRIB="$here"
+PROJECT_ROOT="$CONTRIB/.."
+
+pkgname="secp256k1"
+info "Building $pkgname..."
+
+(
+ cd "$CONTRIB"
+ if [ ! -d secp256k1 ]; then
+ 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 -dfxq
+ git checkout "${LIBSECP_VERSION}^{commit}"
+
+ if ! [ -x configure ] ; then
+ echo "LDFLAGS = -no-undefined" >> Makefile.am
+ ./autogen.sh || fail "Could not run autogen for $pkgname. Please make sure you have automake and libtool installed, and try again."
+ fi
+ if ! [ -r config.status ] ; then
+ ./configure \
+ $AUTOCONF_FLAGS \
+ --prefix="$here/$pkgname/dist" \
+ --enable-module-recovery \
+ --enable-module-extrakeys \
+ --enable-module-schnorrsig \
+ --enable-experimental \
+ --enable-module-ecdh \
+ --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
+ make "-j$CPU_COUNT" || fail "Could not build $pkgname"
+ make install || fail "Could not install $pkgname"
+ . "$here/$pkgname/dist/lib/libsecp256k1.la"
+ host_strip "$here/$pkgname/dist/lib/$dlname"
+ if [ -n "$DLL_TARGET_DIR" ] ; then
+ cp -fpv "$here/$pkgname/dist/lib/$dlname" "$DLL_TARGET_DIR/" || fail "Could not copy the $pkgname binary to DLL_TARGET_DIR"
+ else
+ cp -fpv "$here/$pkgname/dist/lib/$dlname" "$PROJECT_ROOT/electrum" || fail "Could not copy the $pkgname binary to its destination"
+ info "$dlname has been placed in the 'electrum' folder."
+ fi
+)
diff --git a/contrib/make_libusb.sh b/contrib/make_libusb.sh
new file mode 100755
index 000000000000..4666caef167b
--- /dev/null
+++ b/contrib/make_libusb.sh
@@ -0,0 +1,63 @@
+#!/bin/bash
+
+LIBUSB_VERSION="d52e355daa09f17ce64819122cb067b8a2ee0d4b"
+# ^ tag v1.0.27
+
+set -e
+
+. "$(dirname "$0")/build_tools_util.sh" || (echo "Could not source build_tools_util.sh" && exit 1)
+
+here="$(dirname "$(realpath "$0" 2> /dev/null || grealpath "$0")")"
+CONTRIB="$here"
+PROJECT_ROOT="$CONTRIB/.."
+
+pkgname="libusb"
+info "Building $pkgname..."
+
+(
+ cd "$CONTRIB"
+ if [ ! -d libusb ]; then
+ git clone https://github.com/libusb/libusb.git
+ fi
+ cd libusb
+ if ! $(git cat-file -e ${LIBUSB_VERSION}) ; then
+ info "Could not find requested version $LIBUSB_VERSION in local clone; fetching..."
+ git fetch --all
+ fi
+ git reset --hard
+ git clean -dfxq
+ git checkout "${LIBUSB_VERSION}^{commit}"
+
+ if [ "$BUILD_TYPE" = "wine" ] ; then
+ echo "libusb_1_0_la_LDFLAGS += -Wc,-static" >> libusb/Makefile.am
+ fi
+ ./bootstrap.sh || fail "Could not bootstrap libusb"
+ if ! [ -r config.status ] ; then
+ if [ "$BUILD_TYPE" = "wine" ] ; then
+ # windows target
+ LDFLAGS="-Wl,--no-insert-timestamp"
+ elif [ $(uname) == "Darwin" ]; then
+ # macos target
+ LDFLAGS="-Wl -lm"
+ else
+ # linux target
+ LDFLAGS=""
+ fi
+ LDFLAGS="$LDFLAGS" ./configure \
+ $AUTOCONF_FLAGS \
+ || fail "Could not configure $pkgname. Please make sure you have a C compiler installed and try again."
+ fi
+ make "-j$CPU_COUNT" || fail "Could not build $pkgname"
+ make install || warn "Could not install $pkgname"
+ . "$here/$pkgname/libusb/.libs/libusb-1.0.la"
+ host_strip "$here/$pkgname/libusb/.libs/$dlname"
+ TARGET_NAME="$dlname"
+ if [ $(uname) == "Darwin" ]; then # on mac, dlname is "libusb-1.0.0.dylib"
+ TARGET_NAME="libusb-1.0.dylib"
+ fi
+ cp -fpv "$here/$pkgname/libusb/.libs/$dlname" "$PROJECT_ROOT/electrum/$TARGET_NAME" || fail "Could not copy the $pkgname binary to its destination"
+ info "$TARGET_NAME has been placed in the inner 'electrum' folder."
+ if [ -n "$DLL_TARGET_DIR" ] ; then
+ cp -fpv "$here/$pkgname/libusb/.libs/$dlname" "$DLL_TARGET_DIR/$TARGET_NAME" || fail "Could not copy the $pkgname binary to DLL_TARGET_DIR"
+ fi
+)
diff --git a/contrib/make_locale b/contrib/make_locale
deleted file mode 100755
index 9243a8b4fb20..000000000000
--- a/contrib/make_locale
+++ /dev/null
@@ -1,71 +0,0 @@
-#!/usr/bin/env python3
-import os
-import io
-import zipfile
-import requests
-
-os.chdir(os.path.dirname(os.path.realpath(__file__)))
-os.chdir('..')
-
-# Generate fresh translation template
-if not os.path.exists('lib/locale'):
- os.mkdir('lib/locale')
-cmd = 'xgettext -s --no-wrap -f app.fil --output=lib/locale/messages.pot'
-print('Generate template')
-os.system(cmd)
-
-os.chdir('lib')
-
-crowdin_identifier = 'electrum'
-crowdin_file_name = 'electrum-client/messages.pot'
-locale_file_name = 'locale/messages.pot'
-crowdin_api_key = None
-
-filename = '~/.crowdin_api_key'
-if os.path.exists(filename):
- with open(filename) as f:
- crowdin_api_key = f.read().strip()
-
-if "crowdin_api_key" in os.environ:
- crowdin_api_key = os.environ["crowdin_api_key"]
-
-if crowdin_api_key:
- # Push to Crowdin
- print('Push to Crowdin')
- url = ('https://api.crowdin.com/api/project/' + crowdin_identifier + '/update-file?key=' + crowdin_api_key)
- with open(locale_file_name,'rb') as f:
- files = {crowdin_file_name: f}
- requests.request('POST', url, files=files)
- # Build translations
- print('Build translations')
- response = requests.request('GET', 'https://api.crowdin.com/api/project/' + crowdin_identifier + '/export?key=' + crowdin_api_key).content
- print(response)
-
-# Download & unzip
-print('Download translations')
-s = requests.request('GET', 'https://crowdin.com/download/project/' + crowdin_identifier + '.zip').content
-zfobj = zipfile.ZipFile(io.BytesIO(s))
-
-print('Unzip translations')
-for name in zfobj.namelist():
- if not name.startswith('electrum-client/locale'):
- continue
- if name.endswith('/'):
- if not os.path.exists(name[16:]):
- os.mkdir(name[16:])
- else:
- with open(name[16:], 'wb') as output:
- output.write(zfobj.read(name))
-
-# Convert .po to .mo
-print('Installing')
-for lang in os.listdir('locale'):
- if lang.startswith('messages'):
- continue
- # Check LC_MESSAGES folder
- mo_dir = 'locale/%s/LC_MESSAGES' % lang
- if not os.path.exists(mo_dir):
- os.mkdir(mo_dir)
- cmd = 'msgfmt --output-file="%s/electrum.mo" "locale/%s/electrum.po"' % (mo_dir,lang)
- print('Installing', lang)
- os.system(cmd)
diff --git a/contrib/make_osx b/contrib/make_osx
deleted file mode 100755
index f354e331a9e8..000000000000
--- a/contrib/make_osx
+++ /dev/null
@@ -1,6 +0,0 @@
-#!/bin/bash
-rm -rf dist
-export PYTHONHASHSEED=22
-VERSION=`git describe --tags`
-pyinstaller --noconfirm --ascii --name $VERSION contrib/osx.spec
-hdiutil create -fs HFS+ -volname "Electrum" -srcfolder dist/Electrum.app dist/electrum-$VERSION.dmg
diff --git a/contrib/make_packages b/contrib/make_packages
deleted file mode 100755
index 1f7c1fa9c693..000000000000
--- a/contrib/make_packages
+++ /dev/null
@@ -1,12 +0,0 @@
-#!/bin/bash
-
-contrib=$(dirname "$0")
-
-whereis pip3
-if [ $? -ne 0 ] ; then echo "Install pip3" ; exit ; fi
-
-rm $contrib/../packages/ -r
-
-#Install pure python modules in electrum directory
-pip3 install -r $contrib/requirements.txt -t $contrib/../packages
-
diff --git a/contrib/make_packages.sh b/contrib/make_packages.sh
new file mode 100755
index 000000000000..613612c82c50
--- /dev/null
+++ b/contrib/make_packages.sh
@@ -0,0 +1,61 @@
+#!/bin/bash
+# This script installs our pure python dependencies into the 'packages' folder.
+
+set -e
+
+CONTRIB="$(dirname "$(readlink -e "$0")")"
+PROJECT_ROOT="$CONTRIB"/..
+PACKAGES="$PROJECT_ROOT"/packages/
+
+test -n "$CONTRIB" -a -d "$CONTRIB" || exit
+cd "$CONTRIB"
+
+if [ -d "$PACKAGES" ]; then
+ rm -r "$PACKAGES"
+fi
+
+# create virtualenv
+# note: venv path needs to be deterministic as some produced files will contain it
+venv_dir="$CONTRIB/.venv_make_packages/"
+rm -rf "$venv_dir"
+python3 -m venv "$venv_dir"
+source "$venv_dir"/bin/activate
+
+# installing pinned build-time requirements, such as pip/wheel/setuptools
+python3 -m pip install --no-build-isolation --no-dependencies --no-warn-script-location \
+ -r "$CONTRIB"/deterministic-build/requirements-build-base.txt
+
+# opt out of compiling C extensions
+export AIOHTTP_NO_EXTENSIONS=1
+export YARL_NO_EXTENSIONS=1
+export MULTIDICT_NO_EXTENSIONS=1
+export FROZENLIST_NO_EXTENSIONS=1
+export PROPCACHE_NO_EXTENSIONS=1
+
+export ELECTRUM_ECC_DONT_COMPILE=1
+
+# see https://github.com/python-websockets/websockets/blob/e6d0ea1d6b13a979924329d02fb82f79d82c7236/setup.py#L22
+export BUILD_EXTENSION="no"
+
+
+# if we end up having to compile something, at least give reproducibility a fighting chance
+export LC_ALL=C
+export TZ=UTC
+export SOURCE_DATE_EPOCH="$(git log -1 --pretty=%ct 2>/dev/null || printf 1530212462)"
+export PYTHONHASHSEED="$SOURCE_DATE_EPOCH"
+export BUILD_DATE="$(LC_ALL=C TZ=UTC date +'%b %e %Y' -d @$SOURCE_DATE_EPOCH)"
+export BUILD_TIME="$(LC_ALL=C TZ=UTC date +'%H:%M:%S' -d @$SOURCE_DATE_EPOCH)"
+
+# FIXME aiohttp will compile some .so files using distutils
+# (until https://github.com/aio-libs/aiohttp/pull/4079 gets released),
+# which are not reproducible unless using at least python 3.9
+# (as it needs https://github.com/python/cpython/commit/0d30ae1a03102de07758650af9243fd31211325a).
+# Hence "aiohttp-*.dist-info/" is not reproducible either.
+# All this means that downstream users of this script, such as the sdist build
+# and the android apk build need to make sure these files get excluded.
+# note: --no-build-isolation is needed so that pip uses the locally available setuptools and wheel,
+# instead of downloading the latest ones
+python3 -m pip install --no-build-isolation --no-compile --no-dependencies --no-binary :all: \
+ -r "$CONTRIB"/deterministic-build/requirements.txt -t "$PACKAGES"
+
+echo "Pure-python dependencies have been placed into $PACKAGES"
diff --git a/contrib/make_plugin b/contrib/make_plugin
new file mode 100755
index 000000000000..ae256fcb1386
--- /dev/null
+++ b/contrib/make_plugin
@@ -0,0 +1,52 @@
+#!/usr/bin/python3
+import os
+import sys
+import hashlib
+import json
+import zipfile
+import zipimport
+
+# todo: use version number
+
+if len(sys.argv) != 2:
+ print(f"usage: {os.path.basename(__file__)} ", file=sys.stderr)
+ sys.exit(1)
+
+
+source_dir = sys.argv[1] # where the plugin source code is
+if source_dir.endswith('/'):
+ source_dir = source_dir[:-1]
+
+plugin_name = os.path.basename(source_dir)
+dest_dir = os.getcwd()
+zip_path = os.path.join(dest_dir, plugin_name + '.zip')
+
+# remove old zipfile
+if os.path.exists(zip_path):
+ os.unlink(zip_path)
+# create zipfile
+print('creating', zip_path)
+with zipfile.ZipFile(zip_path, 'w') as zip_object:
+ for folder_name, sub_folders, file_names in os.walk(source_dir):
+ for filename in file_names:
+ file_path = os.path.join(folder_name, filename)
+ dest_path = os.path.join(plugin_name, os.path.relpath(folder_name, source_dir), os.path.basename(file_path))
+ zip_object.write(file_path, dest_path)
+ print('added', dest_path)
+
+# read version
+try:
+ with open(os.path.join(source_dir, 'manifest.json'), 'r') as f:
+ manifest = json.load(f)
+ version = manifest.get('version')
+except FileNotFoundError:
+ raise Exception(f"plugin doesn't contain manifest.json")
+
+if version:
+ versioned_plugin_name = plugin_name + '-' + version + '.zip'
+ zip_path_with_version = os.path.join(dest_dir, versioned_plugin_name)
+ # rename zip file
+ os.rename(zip_path, zip_path_with_version)
+ print(f'Created {zip_path_with_version}')
+else:
+ print(f'Created {zip_path}')
diff --git a/contrib/make_zbar.sh b/contrib/make_zbar.sh
new file mode 100755
index 000000000000..e286f30f0c3b
--- /dev/null
+++ b/contrib/make_zbar.sh
@@ -0,0 +1,94 @@
+#!/bin/bash
+
+# This script can be used on Linux hosts to build native libzbar binaries.
+# sudo apt-get install pkg-config libx11-dev libx11-6 libv4l-dev libxv-dev libxext-dev libjpeg-dev
+#
+# It can also be used to cross-compile to Windows:
+# $ sudo apt-get install mingw-w64 mingw-w64-tools win-iconv-mingw-w64-dev
+# For a Windows x86 (32-bit) target, run:
+# $ GCC_TRIPLET_HOST="i686-w64-mingw32" BUILD_TYPE="wine" ./contrib/make_zbar.sh
+# Or for a Windows x86_64 (64-bit) target, run:
+# $ GCC_TRIPLET_HOST="x86_64-w64-mingw32" BUILD_TYPE="wine" ./contrib/make_zbar.sh
+
+ZBAR_VERSION="bb05ec54eec57f8397cb13fb9161372a281a1219"
+# ^ tag 0.23.93
+
+set -e
+
+. "$(dirname "$0")/build_tools_util.sh" || (echo "Could not source build_tools_util.sh" && exit 1)
+
+here="$(dirname "$(realpath "$0" 2> /dev/null || grealpath "$0")")"
+CONTRIB="$here"
+PROJECT_ROOT="$CONTRIB/.."
+
+pkgname="zbar"
+info "Building $pkgname..."
+
+(
+ cd "$CONTRIB"
+ if [ ! -d zbar ]; then
+ git clone https://github.com/mchehab/zbar.git
+ fi
+ cd zbar
+ if ! $(git cat-file -e ${ZBAR_VERSION}) ; then
+ info "Could not find requested version $ZBAR_VERSION in local clone; fetching..."
+ git fetch --all
+ fi
+ git reset --hard
+ git clean -dfxq
+ git checkout "${ZBAR_VERSION}^{commit}"
+
+ if [ "$BUILD_TYPE" = "wine" ] ; then
+ echo "libzbar_la_LDFLAGS += -Wc,-static" >> zbar/Makefile.am
+ echo "LDFLAGS += -Wc,-static" >> Makefile.am
+ fi
+ if ! [ -x configure ] ; then
+ autoreconf -vfi || fail "Could not run autoreconf for $pkgname. Please make sure you have automake and libtool installed, and try again."
+ fi
+ if ! [ -r config.status ] ; then
+ if [ "$BUILD_TYPE" = "wine" ] ; then
+ # windows target
+ AUTOCONF_FLAGS="$AUTOCONF_FLAGS \
+ --with-x=no \
+ --enable-video=yes \
+ --with-jpeg=no \
+ --with-directshow=yes \
+ --disable-dependency-tracking"
+ elif [ $(uname) == "Darwin" ]; then
+ # macos target
+ AUTOCONF_FLAGS="$AUTOCONF_FLAGS \
+ --with-x=no \
+ --enable-video=no \
+ --with-jpeg=no"
+ else
+ # linux target
+ AUTOCONF_FLAGS="$AUTOCONF_FLAGS \
+ --with-x=yes \
+ --enable-video=yes \
+ --with-jpeg=yes"
+ fi
+ ./configure \
+ $AUTOCONF_FLAGS \
+ --prefix="$here/$pkgname/dist" \
+ --enable-pthread=no \
+ --enable-doc=no \
+ --with-python=no \
+ --with-gtk=no \
+ --with-qt=no \
+ --with-java=no \
+ --with-imagemagick=no \
+ --with-dbus=no \
+ --enable-codes=qrcode \
+ --disable-static \
+ --enable-shared || fail "Could not configure $pkgname. Please make sure you have a C compiler installed and try again."
+ fi
+ make "-j$CPU_COUNT" || fail "Could not build $pkgname"
+ make install || fail "Could not install $pkgname"
+ . "$here/$pkgname/dist/lib/libzbar.la"
+ host_strip "$here/$pkgname/dist/lib/$dlname"
+ cp -fpv "$here/$pkgname/dist/lib/$dlname" "$PROJECT_ROOT/electrum" || fail "Could not copy the $pkgname binary to its destination"
+ info "$dlname has been placed in the inner 'electrum' folder."
+ if [ -n "$DLL_TARGET_DIR" ] ; then
+ cp -fpv "$here/$pkgname/dist/lib/$dlname" "$DLL_TARGET_DIR/" || fail "Could not copy the $pkgname binary to DLL_TARGET_DIR"
+ fi
+)
diff --git a/contrib/osx.spec b/contrib/osx.spec
deleted file mode 100644
index fda29455eeb3..000000000000
--- a/contrib/osx.spec
+++ /dev/null
@@ -1,83 +0,0 @@
-# -*- mode: python -*-
-
-from PyInstaller.utils.hooks import collect_data_files, collect_submodules
-
-import sys
-for i, x in enumerate(sys.argv):
- if x == '--name':
- VERSION = sys.argv[i+1]
- break
-else:
- raise BaseException('no version')
-
-home = '/Users/voegtlin/electrum/'
-block_cipher=None
-
-# see https://github.com/pyinstaller/pyinstaller/issues/2005
-hiddenimports = []
-hiddenimports += collect_submodules('trezorlib')
-hiddenimports += collect_submodules('btchip')
-hiddenimports += collect_submodules('keepkeylib')
-
-datas = [
- (home+'lib/currencies.json', 'electrum'),
- (home+'lib/servers.json', 'electrum'),
- (home+'lib/checkpoints.json', 'electrum'),
- (home+'lib/wordlist/english.txt', 'electrum/wordlist'),
- (home+'lib/locale', 'electrum/locale'),
- (home+'plugins', 'electrum_plugins'),
-]
-datas += collect_data_files('trezorlib')
-datas += collect_data_files('btchip')
-datas += collect_data_files('keepkeylib')
-
-# We don't put these files in to actually include them in the script but to make the Analysis method scan them for imports
-a = Analysis([home+'electrum',
- home+'gui/qt/main_window.py',
- home+'gui/text.py',
- home+'lib/util.py',
- home+'lib/wallet.py',
- home+'lib/simple_config.py',
- home+'lib/bitcoin.py',
- home+'lib/dnssec.py',
- home+'lib/commands.py',
- home+'plugins/cosigner_pool/qt.py',
- home+'plugins/email_requests/qt.py',
- home+'plugins/trezor/client.py',
- home+'plugins/trezor/qt.py',
- home+'plugins/keepkey/qt.py',
- home+'plugins/ledger/qt.py',
- ],
- datas=datas,
- hiddenimports=hiddenimports,
- hookspath=[])
-
-# http://stackoverflow.com/questions/19055089/pyinstaller-onefile-warning-pyconfig-h-when-importing-scipy-or-scipy-signal
-for d in a.datas:
- if 'pyconfig' in d[0]:
- a.datas.remove(d)
- break
-
-pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
-
-exe = EXE(pyz,
- a.scripts,
- a.binaries,
- a.datas,
- name='Electrum',
- debug=False,
- strip=False,
- upx=True,
- icon=home+'electrum.icns',
- console=False)
-
-app = BUNDLE(exe,
- version = VERSION,
- name='Electrum.app',
- icon=home+'electrum.icns',
- bundle_identifier=None,
- info_plist = {
- 'NSHighResolutionCapable':'True'
- }
-)
-
diff --git a/contrib/osx/README.md b/contrib/osx/README.md
new file mode 100644
index 000000000000..cfd8ac73ee6c
--- /dev/null
+++ b/contrib/osx/README.md
@@ -0,0 +1,230 @@
+Building macOS binaries
+=======================
+
+✓ _This binary should be reproducible, meaning you should be able to generate
+ binaries that match the official releases._
+
+- _Minimum supported target system (i.e. what end-users need): macOS 11_
+
+This guide explains how to build Electrum binaries for macOS systems.
+
+
+## Building the binary
+
+This needs to be done on a system running macOS or OS X.
+
+The script is only tested on Intel-based (x86_64) Macs, and the binary built
+targets `x86_64` currently.
+
+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 (targets) the binaries also
+ need to be notarized by Apple's central server. To be able to build
+ binaries that Apple will notarize (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).
+ - There are two tools that can be used to notarize a binary, both part of Xcode:
+ the old `altool` and the newer `notarytool`. `altool`
+ [was deprecated](https://developer.apple.com/news/?id=y5mjxqmn) by Apple.
+ `notarytool` requires Xcode 13+, and that in turn requires macOS 11.3+.
+
+We currently build the release binaries on macOS 11.7.10, and these seem to run on
+11 or newer.
+
+
+#### Notes about reproducibility
+
+- We recommend creating a VM with a macOS guest, e.g. using VirtualBox,
+ and building there.
+- The guest should run macOS 11.7.10 (that specific version).
+- The unix username should be `vagrant`, and `electrum` should be cloned directly
+ to the user's home dir: `/Users/vagrant/electrum`.
+- Builders need to use the same version of Xcode; and note that
+ full Xcode and Xcode commandline tools differ!
+ We use the Xcode CLI tools as installed by brew. (version 13.2)
+
+ Sanity checks:
+ ```
+ $ sw_vers
+ ProductName: macOS
+ ProductVersion: 11.7.10
+ BuildVersion: 20G1427
+ $ xcode-select -p
+ /Library/Developer/CommandLineTools
+ $ xcrun --show-sdk-path
+ /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk
+ $ pkgutil --pkg-info=com.apple.pkg.CLTools_Executables
+ package-id: com.apple.pkg.CLTools_Executables
+ version: 13.2.0.0.1.1638488800
+ volume: /
+ location: /
+ install-time: XXXXXXXXXX
+ groups: com.apple.FindSystemFiles.pkg-group
+ $ gcc --version
+ Configured with: --prefix=/Library/Developer/CommandLineTools/usr --with-gxx-include-dir=/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/4.2.1
+ Apple clang version 13.0.0 (clang-1300.0.29.30)
+ Target: x86_64-apple-darwin20.6.0
+ Thread model: posix
+ InstalledDir: /Library/Developer/CommandLineTools/usr/bin
+ ```
+- Installing extraneous brew packages can result in build differences.
+ For example, pyinstaller seems to pick up and bundle brew-installed `libffi`.
+ So having a dedicated "electrum binary builder macOS VM" is recommended.
+- Make sure that you are building from a fresh clone of electrum
+ (or run e.g. `git clean -ffxd` to rm all local changes).
+
+
+#### 1. Install brew
+
+Install [`brew`](https://brew.sh/).
+
+Let brew install the Xcode CLI tools.
+
+
+#### 2. Build Electrum
+
+ cd electrum
+ ./contrib/osx/make_osx.sh
+
+This creates both a folder named Electrum.app and the .dmg file (both unsigned).
+
+##### 2.1. For release binaries, here be dragons
+
+If you want the binaries codesigned for macOS and notarised by Apple's central server,
+also run the `sign_osx.sh` script:
+
+ CODESIGN_CERT="Developer ID Application: Electrum Technologies GmbH (L6P37P7P56)" \
+ APPLE_TEAM_ID="L6P37P7P56" \
+ APPLE_ID_USER="me@email.com" \
+ APPLE_ID_PASSWORD="1234" \
+ ./contrib/osx/sign_osx.sh
+
+(note: `APPLE_ID_PASSWORD` is an app-specific password, *not* the account password)
+
+
+## Verifying reproducibility and comparing against official binary
+
+Every user can verify that the official binary was created from the source code in this
+repository.
+
+1. Build your own binary as described above.
+2. Use the provided `compare_dmg` script to compare the binary you built with
+ the official release binary.
+ ```
+ $ ./contrib/osx/compare_dmg dist/electrum-*.dmg electrum_dmg_official_release.dmg
+ ```
+ The `compare_dmg` script is mostly only needed as the official release binary is
+ codesigned and notarized. Otherwise, the built `.app` bundles should be byte-identical.
+ (Note that we are using `hdutil` to create the `.dmg`, and its output is not
+ deterministic, but we cannot compare the `.dmg` files directly anyway as they contain
+ codesigned files)
+
+
+## FAQ
+
+### What is macOS "codesigning" and "notarization"?
+
+Codesigning is the macOS OS-native signing of executables/shared-libs,
+that needs to be done using an ~x509-like certificate that chains back to Apple's root CA.
+Once a developer certificate is obtained from Apple, it can be used to codesign locally
+on a dev machine.
+
+Notarization is a further step usually done after, which entails uploading a distributable
+over the network to the Apple mothership central server, which runs some arbitrary checks on it,
+and if it finds the file ok, the central server gives the dev a notarization staple.
+This staple can then be optionally "attached" to the distributable, mutating it, which we do.
+(If the staple is not attached, enduser machines request it from the mothership at runtime.)
+
+Both these steps should be done during the build process.
+
+### What is "codesigned" and/or "notarized", re the official release?
+
+- `make_osx.sh` builds a `.app`, which is unsigned/unnotarized
+ - at this point, this `.app` is ~"byte-for-byte" reproducible
+ - this is the sanity-check hash printed at the end of `make_osx.sh`
+ - `make_osx.sh` creates a `.dmg` from the `.app`
+ - this `.dmg` is not used for the official release at all, but used as the basis of
+ testing reproducibility using the `compare_dmg` script
+- `sign_osx.sh` codesigns the `.app` (mutating it)
+- `sign_osx.sh` -> `notarize_app.sh` notarizes the `.app` (mutating it)
+- `sign_osx.sh` creates a `.dmg` from the `.app`
+- `sign_osx.sh` codesigns the `.dmg` (mutating it)
+ - this `.dmg` becomes the official release distributable
+
+That is, the official release `.dmg` is codesigned but NOT notarized.
+It contains a `.app`, which is codesigned AND notarized.
+
+### How to check if a file is codesigned?
+
+Both the `.dmg` and the contained `.app` are codesigned:
+```
+$ codesign --verify --deep --strict --verbose=2 $HOME/Desktop/electrum-4.5.8.dmg && echo "signed"
+/Users/vagrant/Desktop/electrum-4.5.8.dmg: valid on disk
+/Users/vagrant/Desktop/electrum-4.5.8.dmg: satisfies its Designated Requirement
+signed
+```
+```
+$ codesign --verify --deep --strict --verbose=1 $HOME/Desktop/Electrum-4.5.8.app && echo "signed"
+/Users/vagrant/Desktop/Electrum-4.5.8.app: valid on disk
+/Users/vagrant/Desktop/Electrum-4.5.8.app: satisfies its Designated Requirement
+signed
+```
+
+Also see `$ codesign -dvvv $HOME/Desktop/electrum-4.5.8.dmg`
+
+### How to check if a file is notarized?
+
+The outer `.dmg` is NOT notarized, but the inner `.app` is notarized:
+```
+$ spctl -a -vvv -t install $HOME/Desktop/electrum-4.5.8.dmg
+/Users/vagrant/Desktop/electrum-4.5.8.dmg: rejected
+source=Unnotarized Developer ID
+origin=Developer ID Application: Electrum Technologies GmbH (L6P37P7P56)
+```
+```
+$ spctl -a -vvv -t install $HOME/Desktop/Electrum-4.5.8.app
+/Users/vagrant/Desktop/Electrum-4.5.8.app: accepted
+source=Notarized Developer ID
+origin=Developer ID Application: Electrum Technologies GmbH (L6P37P7P56)
+```
+
+### How to simulate the signing procedure?
+
+It is possible to run `sign_osx.sh` using a self-signed certificate to test the
+signing procedure without using a production certificate.
+
+Note that the notarization process will be skipped as it is not possible to notarize
+an executable with Apple using a self-signed certificate.
+
+#### To generate a self-signed certificate, inside your **MacOS VM**:
+1. Open the `Keychain Access` application.
+2. In the menubar go to `Keychain Access` > `Certificate Assistant` > `Create a Certificate...`
+3. Set a name (e.g. `signing_dummy`)
+4. Change `Certificate Type` to *'Code Signing'*
+5. Click `Create` and `Continue`.
+
+You now have a self-signed certificate `signing_dummy` added to your `login` keychain.
+
+#### To sign the executables with the self-signed certificate:
+
+Assuming you have the two unsigned outputs of `make_osx.sh` inside `~/electrum/dist`
+(e.g. `Electrum.app` and `electrum-4.5.4-1368-gc8db684cc-unsigned.dmg`).
+
+In `~/electrum` run:
+
+`$ CODESIGN_CERT="signing_dummy" ./contrib/osx/sign_osx.sh`
+
+After `sign_osx.sh` finished, you will have a new `*.dmg` inside `electrum/dist`
+(without the `-unsigned` postfix) which is signed with your certificate.
+
+#### To compare the unsigned executable with the self-signed executable:
+
+Running `compare_dmg` with `IS_NOTARIZED=false` should succeed:
+
+`$ IS_NOTARIZED=false ./electrum/contrib/osx/compare_dmg `
\ No newline at end of file
diff --git a/contrib/osx/README_macos.md b/contrib/osx/README_macos.md
new file mode 100644
index 000000000000..a56e63c8d701
--- /dev/null
+++ b/contrib/osx/README_macos.md
@@ -0,0 +1,37 @@
+# Running Electrum from source on macOS (development version)
+
+## Prerequisites
+
+- [brew](https://brew.sh/)
+- python3
+- git
+
+## Main steps
+
+### 1. Check out the code from GitHub:
+```
+$ git clone https://github.com/spesmilo/electrum.git
+$ cd electrum
+$ git submodule update --init
+```
+
+### 2. Prepare for compiling libsecp256k1
+
+To be able to build the `electrum-ecc` package from source
+(which is pulled in when installing Electrum in the next step),
+you need:
+```
+$ brew install autoconf automake libtool coreutils
+```
+
+### 3. Install Electrum
+
+Run install (this should install the dependencies):
+```
+$ python3 -m pip install --user -e ".[gui,crypto]"
+```
+
+### 4. Run electrum:
+```
+$ ./run_electrum
+```
diff --git a/contrib/osx/apply_sigs.sh b/contrib/osx/apply_sigs.sh
new file mode 100755
index 000000000000..c79d70374816
--- /dev/null
+++ b/contrib/osx/apply_sigs.sh
@@ -0,0 +1,58 @@
+#!/bin/sh
+# Copyright (c) 2014-2019 The Bitcoin Core developers
+# Distributed under the MIT software license, see the accompanying
+# file COPYING or http://www.opensource.org/licenses/mit-license.php.
+#
+# This script is based on https://github.com/bitcoin/bitcoin/blob/194b9b8792d9b0798fdb570b79fa51f1d1f5ebaf/contrib/macdeploy/detached-sig-apply.sh
+
+export LC_ALL=C
+set -e
+
+if [ $(uname) != "Darwin" ]; then
+ echo "This script needs to be run on macOS."
+ exit 1
+fi
+
+CP=gcp
+
+UNSIGNED="$1"
+SIGNATURE="$2"
+ARCH=x86_64
+OUTDIR="/tmp/electrum_compare_dmg/signed_app"
+
+if [ -z "$UNSIGNED" ]; then
+ echo "usage: $0 "
+ exit 1
+fi
+
+if [ -z "$SIGNATURE" ]; then
+ echo "usage: $0 "
+ exit 1
+fi
+
+rm -rf ${OUTDIR} && mkdir -p ${OUTDIR}
+${CP} -rf ${UNSIGNED} ${OUTDIR}
+tar xf "${SIGNATURE}" -C ${OUTDIR}
+
+find ${OUTDIR} -name "*.sign" | while read i; do
+ SIZE=$(gstat -c %s "${i}")
+ TARGET_FILE="$(echo "${i}" | sed 's/\.sign$//')"
+
+ if [ -z ${QUIET} ]; then
+ echo "Allocating space for the signature of size ${SIZE} in ${TARGET_FILE}"
+ fi
+ codesign_allocate -i "${TARGET_FILE}" -a ${ARCH} ${SIZE} -o "${i}.tmp"
+
+ OFFSET=$(pagestuff "${i}.tmp" -p | tail -2 | grep offset | sed 's/[^0-9]*//g')
+ if [ -z ${QUIET} ]; then
+ echo "Attaching signature at offset ${OFFSET}"
+ fi
+
+ dd if="$i" of="${i}.tmp" bs=1 seek=${OFFSET} count=${SIZE} 2>/dev/null
+ mv "${i}.tmp" "${TARGET_FILE}"
+ rm "${i}"
+ if [ -z ${QUIET} ]; then
+ echo "Success."
+ fi
+done
+echo "Done. .app with sigs applied is at: ${OUTDIR}"
diff --git a/contrib/osx/cdrkit-deterministic.patch b/contrib/osx/cdrkit-deterministic.patch
new file mode 100644
index 000000000000..d01e5b75e70d
--- /dev/null
+++ b/contrib/osx/cdrkit-deterministic.patch
@@ -0,0 +1,86 @@
+--- cdrkit-1.1.11.old/genisoimage/tree.c 2008-10-21 19:57:47.000000000 -0400
++++ cdrkit-1.1.11/genisoimage/tree.c 2013-12-06 00:23:18.489622668 -0500
+@@ -1139,8 +1139,9 @@
+ scan_directory_tree(struct directory *this_dir, char *path,
+ struct directory_entry *de)
+ {
+- DIR *current_dir;
++ int current_file;
+ char whole_path[PATH_MAX];
++ struct dirent **d_list;
+ struct dirent *d_entry;
+ struct directory *parent;
+ int dflag;
+@@ -1164,7 +1165,8 @@
+ this_dir->dir_flags |= DIR_WAS_SCANNED;
+
+ errno = 0; /* Paranoia */
+- current_dir = opendir(path);
++ //current_dir = opendir(path);
++ current_file = scandir(path, &d_list, NULL, alphasort);
+ d_entry = NULL;
+
+ /*
+@@ -1173,12 +1175,12 @@
+ */
+ old_path = path;
+
+- if (current_dir) {
++ if (current_file >= 0) {
+ errno = 0;
+- d_entry = readdir(current_dir);
++ d_entry = d_list[0];
+ }
+
+- if (!current_dir || !d_entry) {
++ if (current_file < 0 || !d_entry) {
+ int ret = 1;
+
+ #ifdef USE_LIBSCHILY
+@@ -1191,8 +1193,8 @@
+ de->isorec.flags[0] &= ~ISO_DIRECTORY;
+ ret = 0;
+ }
+- if (current_dir)
+- closedir(current_dir);
++ if(d_list)
++ free(d_list);
+ return (ret);
+ }
+ #ifdef ABORT_DEEP_ISO_ONLY
+@@ -1208,7 +1210,7 @@
+ errmsgno(EX_BAD, "use Rock Ridge extensions via -R or -r,\n");
+ errmsgno(EX_BAD, "or allow deep ISO9660 directory nesting via -D.\n");
+ }
+- closedir(current_dir);
++ free(d_list);
+ return (1);
+ }
+ #endif
+@@ -1250,13 +1252,13 @@
+ * The first time through, skip this, since we already asked
+ * for the first entry when we opened the directory.
+ */
+- if (dflag)
+- d_entry = readdir(current_dir);
++ if (dflag && current_file >= 0)
++ d_entry = d_list[current_file];
+ dflag++;
+
+- if (!d_entry)
++ if (current_file < 0)
+ break;
+-
++ current_file--;
+ /* OK, got a valid entry */
+
+ /* If we do not want all files, then pitch the backups. */
+@@ -1348,7 +1350,7 @@
+ insert_file_entry(this_dir, whole_path, d_entry->d_name);
+ #endif /* APPLE_HYB */
+ }
+- closedir(current_dir);
++ free(d_list);
+
+ #ifdef APPLE_HYB
+ /*
\ No newline at end of file
diff --git a/contrib/osx/compare_dmg b/contrib/osx/compare_dmg
new file mode 100755
index 000000000000..bd2f0b2fd222
--- /dev/null
+++ b/contrib/osx/compare_dmg
@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+set -e
+
+if [ $(uname) != "Darwin" ]; then
+ echo "This script needs to be run on macOS."
+ exit 1
+fi
+
+UNSIGNED_DMG="$1"
+RELEASE_DMG="$2"
+CONTRIB_OSX="$(dirname "$(grealpath "$0")")"
+PROJECT_ROOT="$CONTRIB_OSX/../.."
+WORKSPACE="/tmp/electrum_compare_dmg"
+WS_VOL1="$WORKSPACE/vol1"
+WS_VOL2="$WORKSPACE/vol2"
+
+if [ -z "$UNSIGNED_DMG" ]; then
+ echo "usage: $0 "
+ exit 1
+fi
+
+if [ -z "$RELEASE_DMG" ]; then
+ echo "usage: $0 "
+ exit 1
+fi
+
+UNSIGNED_DMG=$(grealpath "$UNSIGNED_DMG")
+RELEASE_DMG=$(grealpath "$RELEASE_DMG")
+
+cd "$PROJECT_ROOT"
+rm -rf "$WORKSPACE"
+mkdir -p "$WORKSPACE" "$WS_VOL1" "$WS_VOL2"
+
+DMG_UNSIGNED_UNPACKED="$WORKSPACE/dmg1"
+DMG_RELEASE_UNPACKED="$WORKSPACE/dmg2"
+
+hdiutil attach -mountroot "$WS_VOL1" "$UNSIGNED_DMG"
+cp -r "$WS_VOL1"/Electrum "$DMG_UNSIGNED_UNPACKED"
+hdiutil detach "$WS_VOL1"/Electrum
+
+hdiutil attach -mountroot "$WS_VOL2" "$RELEASE_DMG"
+cp -r "$WS_VOL2"/Electrum "$DMG_RELEASE_UNPACKED"
+hdiutil detach "$WS_VOL2"/Electrum
+
+# copy signatures from RELEASE_DMG to UNSIGNED_DMG
+echo "Extracting signatures from release app..."
+QUIET="1" "$CONTRIB_OSX/extract_sigs.sh" "$DMG_RELEASE_UNPACKED"/Electrum.app
+echo "Applying extracted signatures to unsigned app..."
+QUIET="1" "$CONTRIB_OSX/apply_sigs.sh" "$DMG_UNSIGNED_UNPACKED"/Electrum.app mac_extracted_sigs.tar.gz
+
+rm mac_extracted_sigs.tar.gz
+rm -rf "$DMG_UNSIGNED_UNPACKED"
+
+set -x
+diff=$(diff -qr "$WORKSPACE/signed_app" "$DMG_RELEASE_UNPACKED") || diff="diff errored"
+set +x
+echo $diff
+if [ "$diff" ]; then
+ echo "DMGs do *not* match."
+ echo "failure"
+ exit 1
+else
+ echo "DMGs match."
+ echo "success"
+ exit 0
+fi
diff --git a/contrib/osx/entitlements.plist b/contrib/osx/entitlements.plist
new file mode 100644
index 000000000000..7c93d5b377a0
--- /dev/null
+++ b/contrib/osx/entitlements.plist
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+ com.apple.security.cs.disable-library-validation
+
+
+
+
+ com.apple.security.cs.allow-dyld-environment-variables
+
+ com.apple.security.cs.allow-jit
+
+
+
+ com.apple.security.device.camera
+
+
+
diff --git a/contrib/osx/extract_sigs.sh b/contrib/osx/extract_sigs.sh
new file mode 100755
index 000000000000..698e07d996c7
--- /dev/null
+++ b/contrib/osx/extract_sigs.sh
@@ -0,0 +1,71 @@
+#!/bin/sh
+# Copyright (c) 2014-2019 The Bitcoin Core developers
+# Distributed under the MIT software license, see the accompanying
+# file COPYING or http://www.opensource.org/licenses/mit-license.php.
+#
+# This script is based on https://github.com/bitcoin/bitcoin/blob/194b9b8792d9b0798fdb570b79fa51f1d1f5ebaf/contrib/macdeploy/detached-sig-create.sh
+
+export LC_ALL=C
+set -e
+
+if [ $(uname) != "Darwin" ]; then
+ echo "This script needs to be run on macOS."
+ exit 1
+fi
+
+TEMPDIR="/tmp/electrum_compare_dmg/sigs.temp"
+OUT=mac_extracted_sigs.tar.gz
+OUTROOT=.
+
+if [ -z "$1" ]; then
+ echo "usage: $0 "
+ exit 1
+fi
+BUNDLE="$1"
+BUNDLE_BASENAME=$(basename "$BUNDLE")
+
+rm -rf ${TEMPDIR}
+mkdir -p ${TEMPDIR}
+
+MAYBE_SIGNED_FILES=$(
+ find "$BUNDLE/Contents/MacOS/" -type f;
+ find "$BUNDLE/Contents/Frameworks/" -type f;
+ find "$BUNDLE/Contents/Resources/" -type f
+)
+
+echo "${MAYBE_SIGNED_FILES}" | while read i; do
+ # skip files where pagestuff errors; these probably do not need signing:
+ pagestuff "$i" -p 1>/dev/null 2>/dev/null || continue
+ TARGETFILE="${BUNDLE_BASENAME}/$(echo "${i}" | sed "s|.*${BUNDLE}/||")"
+ SIZE=$(pagestuff "$i" -p | tail -2 | grep size | sed 's/[^0-9]*//g')
+ OFFSET=$(pagestuff "$i" -p | tail -2 | grep offset | sed 's/[^0-9]*//g')
+ SIGNFILE="${TEMPDIR}/${OUTROOT}/${TARGETFILE}.sign"
+ DIRNAME="$(dirname "${SIGNFILE}")"
+ mkdir -p "${DIRNAME}"
+ if [ -z ${QUIET} ]; then
+ echo "Adding detached signature for: ${TARGETFILE}. Size: ${SIZE}. Offset: ${OFFSET}"
+ fi
+ dd if="$i" of="${SIGNFILE}" bs=1 skip=${OFFSET} count=${SIZE} 2>/dev/null
+done
+
+# note: "$BUNDLE/Contents/CodeResources" is the "notarization staple id"
+FILES_TO_COPY=$(cat << EOF
+$BUNDLE/Contents/_CodeSignature/CodeResources
+$([ "${IS_NOTARIZED:-true}" != "false" ] && echo "$BUNDLE/Contents/CodeResources")
+EOF
+)
+
+echo "${FILES_TO_COPY}" | while read i; do
+ TARGETFILE="${BUNDLE_BASENAME}/$(echo "${i}" | sed "s|.*${BUNDLE}/||")"
+ RESOURCE="${TEMPDIR}/${OUTROOT}/${TARGETFILE}"
+ DIRNAME="$(dirname "${RESOURCE}")"
+ mkdir -p "${DIRNAME}"
+ if [ -z ${QUIET} ]; then
+ echo "Adding resource for: \"${TARGETFILE}\""
+ fi
+ cp "${i}" "${RESOURCE}"
+done
+
+tar -C "${TEMPDIR}" -czf "${OUT}" .
+rm -rf "${TEMPDIR}"
+echo "Created ${OUT}"
diff --git a/contrib/osx/make_osx.sh b/contrib/osx/make_osx.sh
new file mode 100755
index 000000000000..608806c7bfcd
--- /dev/null
+++ b/contrib/osx/make_osx.sh
@@ -0,0 +1,227 @@
+#!/usr/bin/env bash
+
+set -e
+
+# Parameterize
+PYTHON_VERSION=3.12.10
+PY_VER_MAJOR="3.12" # as it appears in fs paths
+PACKAGE=Electrum
+GIT_REPO=https://github.com/spesmilo/electrum
+
+export GCC_STRIP_BINARIES="1"
+export PYTHONDONTWRITEBYTECODE=1 # don't create __pycache__/ folders with .pyc files
+
+
+. "$(dirname "$0")/../build_tools_util.sh"
+
+
+CONTRIB_OSX="$(dirname "$(realpath "$0")")"
+CONTRIB="$CONTRIB_OSX/.."
+PROJECT_ROOT="$CONTRIB/.."
+CACHEDIR="$CONTRIB_OSX/.cache"
+export DLL_TARGET_DIR="$CACHEDIR/dlls"
+PIP_CACHE_DIR="$CACHEDIR/pip_cache"
+
+mkdir -p "$CACHEDIR" "$DLL_TARGET_DIR" "$PIP_CACHE_DIR"
+
+cd "$PROJECT_ROOT"
+
+git -C "$PROJECT_ROOT" rev-parse 2>/dev/null || fail "Building outside a git clone is not supported."
+
+
+which brew > /dev/null 2>&1 || fail "Please install brew from https://brew.sh/ to continue"
+which xcodebuild > /dev/null 2>&1 || fail "Please install xcode command line tools to continue"
+
+
+info "Installing Python $PYTHON_VERSION"
+PKG_FILE="python-${PYTHON_VERSION}-macos11.pkg"
+if [ ! -f "$CACHEDIR/$PKG_FILE" ]; then
+ curl -o "$CACHEDIR/$PKG_FILE" "https://www.python.org/ftp/python/${PYTHON_VERSION}/$PKG_FILE"
+fi
+echo "8373e58da4ea146b3eb1c1f9834f19a319440b6b679b06050b1f9ee3237aa8e4 $CACHEDIR/$PKG_FILE" | shasum -a 256 -c \
+ || fail "python pkg checksum mismatched"
+sudo installer -pkg "$CACHEDIR/$PKG_FILE" -target / \
+ || fail "failed to install python"
+
+# sanity check "python3" has the version we just installed.
+FOUND_PY_VERSION=$(python3 -c 'import sys; print(".".join(map(str, sys.version_info[:3])))')
+if [[ "$FOUND_PY_VERSION" != "$PYTHON_VERSION" ]]; then
+ fail "python version mismatch: $FOUND_PY_VERSION != $PYTHON_VERSION"
+fi
+
+break_legacy_easy_install
+
+# create a fresh virtualenv
+# This helps to avoid older versions of pip-installed dependencies interfering with the build.
+VENV_DIR="$CONTRIB_OSX/build-venv"
+rm -rf "$VENV_DIR"
+python3 -m venv "$VENV_DIR"
+source "$VENV_DIR/bin/activate"
+
+# don't add debug info to compiled C files (e.g. when pip calls setuptools/wheel calls gcc)
+# see https://github.com/pypa/pip/issues/6505#issuecomment-526613584
+# note: this does not seem sufficient when cython is involved (although it is on linux, just not on mac... weird.)
+# see additional "strip" pass on built files later in the file.
+export CFLAGS="-g0"
+
+# Do not build universal binaries. The default on macos 11+ and xcode 12+ is "-arch arm64 -arch x86_64"
+# but with that e.g. "hid.cpython-310-darwin.so" is not reproducible as built by clang.
+export ARCHFLAGS="-arch x86_64"
+
+info "Installing build dependencies"
+# note: re pip installing from PyPI,
+# we prefer compiling C extensions ourselves, instead of using binary wheels,
+# hence "--no-binary :all:" flags. However, we specifically allow
+# - PyQt6, as it's harder to build from source
+# - cryptography, as it's harder to build from source
+# - the whole of "requirements-build-base.txt", which includes pip and friends, as it also includes "wheel",
+# and I am not quite sure how to break the circular dependence there (I guess we could introduce
+# "requirements-build-base-base.txt" with just wheel in it...)
+python3 -m pip install --no-build-isolation --no-dependencies --no-warn-script-location \
+ --cache-dir "$PIP_CACHE_DIR" -Ir ./contrib/deterministic-build/requirements-build-base.txt \
+ || fail "Could not install build dependencies (base)"
+python3 -m pip install --no-build-isolation --no-dependencies --no-binary :all: --no-warn-script-location \
+ --cache-dir "$PIP_CACHE_DIR" -Ir ./contrib/deterministic-build/requirements-build-mac.txt \
+ || fail "Could not install build dependencies (mac)"
+
+info "Installing some build-time deps for compilation..."
+brew install autoconf automake libtool gettext coreutils pkgconfig
+
+info "Building PyInstaller."
+PYINSTALLER_REPO="https://github.com/pyinstaller/pyinstaller.git"
+PYINSTALLER_COMMIT="306d4d92580fea7be7ff2c89ba112cdc6f73fac1"
+# ^ tag "v6.13.0"
+(
+ if [ -f "$CACHEDIR/pyinstaller/PyInstaller/bootloader/Darwin-64bit/runw" ]; then
+ info "pyinstaller already built, skipping"
+ exit 0
+ fi
+ cd "$PROJECT_ROOT"
+ ELECTRUM_COMMIT_HASH=$(git rev-parse HEAD)
+ cd "$CACHEDIR"
+ rm -rf pyinstaller
+ mkdir pyinstaller
+ cd pyinstaller
+ # Shallow clone
+ git init
+ git remote add origin $PYINSTALLER_REPO
+ git fetch --depth 1 origin $PYINSTALLER_COMMIT
+ git checkout -b pinned "${PYINSTALLER_COMMIT}^{commit}"
+ rm -fv PyInstaller/bootloader/Darwin-*/run* || 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
+ echo "const char *electrum_tag = \"tagged by Electrum@$ELECTRUM_COMMIT_HASH\";" >> ./bootloader/src/pyi_main.c
+ pushd bootloader
+ # compile bootloader
+ python3 ./waf all CFLAGS="-static"
+ popd
+ # sanity check bootloader is there:
+ [[ -e "PyInstaller/bootloader/Darwin-64bit/runw" ]] || fail "Could not find runw in target dir!"
+)
+info "Installing PyInstaller."
+python3 -m pip install --no-build-isolation --no-dependencies \
+ --cache-dir "$PIP_CACHE_DIR" --no-warn-script-location "$CACHEDIR/pyinstaller"
+
+info "Using these versions for building $PACKAGE:"
+sw_vers
+python3 --version
+echo -n "Pyinstaller "
+pyinstaller --version
+
+rm -rf ./dist
+
+info "resetting git submodules."
+# note: --force is less critical in other build scripts, but as the mac build is not doing a fresh clone,
+# it is very useful here for reproducibility
+git submodule update --init --force
+
+info "preparing electrum-locale."
+(
+ if ! which msgfmt > /dev/null 2>&1; then
+ brew install gettext
+ brew link --force gettext
+ fi
+ "$CONTRIB/locale/build_cleanlocale.sh"
+ # we want the binary to have only compiled (.mo) locale files; not source (.po) files
+ rm -r "$PROJECT_ROOT/electrum/locale/locale"/*/electrum.po
+)
+
+
+if ls "$DLL_TARGET_DIR"/libsecp256k1.*.dylib 1> /dev/null 2>&1; then
+ info "libsecp256k1 already built, skipping"
+else
+ info "Building libsecp256k1 dylib..."
+ "$CONTRIB"/make_libsecp256k1.sh || fail "Could not build libsecp"
+fi
+cp -f "$DLL_TARGET_DIR"/libsecp256k1.*.dylib "$PROJECT_ROOT/electrum" || fail "Could not copy libsecp256k1 dylib"
+
+if [ ! -f "$DLL_TARGET_DIR/libzbar.0.dylib" ]; then
+ info "Building ZBar dylib..."
+ "$CONTRIB"/make_zbar.sh || fail "Could not build ZBar dylib"
+else
+ info "Skipping ZBar build: reusing already built dylib."
+fi
+cp -f "$DLL_TARGET_DIR/libzbar.0.dylib" "$PROJECT_ROOT/electrum/" || fail "Could not copy ZBar dylib"
+
+if [ ! -f "$DLL_TARGET_DIR/libusb-1.0.dylib" ]; then
+ info "Building libusb dylib..."
+ "$CONTRIB"/make_libusb.sh || fail "Could not build libusb dylib"
+else
+ info "Skipping libusb build: reusing already built dylib."
+fi
+cp -f "$DLL_TARGET_DIR/libusb-1.0.dylib" "$PROJECT_ROOT/electrum/" || fail "Could not copy libusb dylib"
+
+
+# opt out of compiling C extensions
+export YARL_NO_EXTENSIONS=1
+export PROPCACHE_NO_EXTENSIONS=1
+
+export ELECTRUM_ECC_DONT_COMPILE=1
+
+info "Installing requirements..."
+python3 -m pip install --no-build-isolation --no-dependencies --no-binary :all: \
+ --cache-dir "$PIP_CACHE_DIR" --no-warn-script-location \
+ -Ir ./contrib/deterministic-build/requirements.txt \
+ || fail "Could not install requirements"
+
+info "Installing hardware wallet requirements..."
+python3 -m pip install --no-build-isolation --no-dependencies --no-binary :all: --only-binary cryptography \
+ --cache-dir "$PIP_CACHE_DIR" --no-warn-script-location \
+ -Ir ./contrib/deterministic-build/requirements-hw.txt \
+ || fail "Could not install hardware wallet requirements"
+
+info "Installing dependencies specific to binaries..."
+python3 -m pip install --no-build-isolation --no-dependencies --no-binary :all: --only-binary PyQt6,PyQt6-Qt6,cryptography \
+ --cache-dir "$PIP_CACHE_DIR" --no-warn-script-location \
+ -Ir ./contrib/deterministic-build/requirements-binaries-mac.txt \
+ || fail "Could not install dependencies specific to binaries"
+
+info "Building $PACKAGE..."
+python3 -m pip install --no-build-isolation --no-dependencies \
+ --cache-dir "$PIP_CACHE_DIR" --no-warn-script-location . > /dev/null || fail "Could not build $PACKAGE"
+# pyinstaller needs to be able to "import electrum_ecc", for which we need libsecp256k1:
+# (or could try "pip install -e" instead)
+cp "$DLL_TARGET_DIR"/libsecp256k1.*.dylib "$VENV_DIR/lib/python$PY_VER_MAJOR/site-packages/electrum_ecc/"
+
+# strip debug symbols of some compiled libs
+# - hidapi (hid.cpython-39-darwin.so) in particular is not reproducible without this
+find "$VENV_DIR/lib/python$PY_VER_MAJOR/site-packages/" -type f -name '*.so' -print0 \
+ | xargs -0 -t strip -x
+
+info "Faking timestamps..."
+find . -exec touch -t '200101220000' {} + || true
+
+# note: no --dirty, as we have dirtied electrum/locale/ ourselves.
+VERSION=$(git describe --tags --always)
+
+info "Building binary"
+ELECTRUM_VERSION=$VERSION pyinstaller --noconfirm --clean contrib/osx/pyinstaller.spec || fail "Could not build binary"
+
+info "Finished building unsigned dist/${PACKAGE}.app. This hash should be reproducible:"
+find "dist/${PACKAGE}.app" -type f -print0 | sort -z | xargs -0 shasum -a 256 | shasum -a 256
+
+info "Creating unsigned .DMG"
+hdiutil create -fs HFS+ -volname $PACKAGE -srcfolder dist/$PACKAGE.app dist/electrum-$VERSION-unsigned.dmg || fail "Could not create .DMG"
+
+info "App was built successfully but was not code signed. Users may get security warnings from macOS."
+info "Now you also need to run sign_osx.sh to codesign/notarize the binary."
diff --git a/contrib/osx/notarize_app.sh b/contrib/osx/notarize_app.sh
new file mode 100755
index 000000000000..7e18c95629a0
--- /dev/null
+++ b/contrib/osx/notarize_app.sh
@@ -0,0 +1,58 @@
+#!/usr/bin/env bash
+# from https://github.com/metabrainz/picard/blob/e1354632d2db305b7a7624282701d34d73afa225/scripts/package/macos-notarize-app.sh
+
+set -e
+
+if [ -z "$1" ]; then
+ echo "Specify app bundle as first parameter"
+ exit 1
+fi
+
+if [ -z "$APPLE_ID_USER" ] || [ -z "$APPLE_ID_PASSWORD" ] || [ -z "$APPLE_TEAM_ID" ]; 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 notarytool submit \
+ --team-id "$APPLE_TEAM_ID" \
+ --apple-id "$APPLE_ID_USER" \
+ --password "$APPLE_ID_PASSWORD" \
+ --output-format plist \
+ --wait \
+ --timeout 10m \
+ "${APP_BUNDLE}.zip"
+)
+
+if [ $? -ne 0 ]; then
+ echo "Submitting $APP_BUNDLE failed:"
+ echo "$RESULT"
+ exit 1
+fi
+
+STATUS=$(echo "$RESULT" | xpath -e \
+ "//key[normalize-space(text()) = 'status']/following-sibling::string[1]/text()" 2> /dev/null)
+
+if [ "$STATUS" = "Accepted" ]; then
+ echo "Notarization of $APP_BUNDLE succeeded!"
+else
+ echo "Notarization of $APP_BUNDLE failed:"
+ echo "$RESULT"
+ exit 1
+fi
+
+# Staple the notary ticket
+xcrun stapler staple "$APP_BUNDLE"
+
+# rm zip
+rm "${APP_BUNDLE}.zip"
diff --git a/contrib/osx/package.sh b/contrib/osx/package.sh
new file mode 100755
index 000000000000..1d4236c1a313
--- /dev/null
+++ b/contrib/osx/package.sh
@@ -0,0 +1,95 @@
+#!/usr/bin/env bash
+
+set -ex
+
+PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/../.."
+CONTRIB="$PROJECT_ROOT/contrib"
+. "$CONTRIB"/build_tools_util.sh
+
+# note: GCC 10.1 will need an extra option, see https://github.com/bitcoin/bitcoin/pull/19553
+
+cdrkit_version=1.1.11
+cdrkit_download_path=http://distro.ibiblio.org/fatdog/source/600/c
+cdrkit_file_name=cdrkit-${cdrkit_version}.tar.bz2
+cdrkit_sha256_hash=b50d64c214a65b1a79afe3a964c691931a4233e2ba605d793eb85d0ac3652564
+cdrkit_patches=cdrkit-deterministic.patch
+genisoimage=genisoimage-$cdrkit_version
+
+libdmg_url=https://github.com/theuni/libdmg-hfsplus
+
+
+export LD_PRELOAD=$(locate libfaketime.so.1)
+export FAKETIME="2000-01-22 00:00:00"
+export PATH=$PATH:~/bin
+
+
+if [ -z "$1" ]; then
+ echo "Usage: $0 Electrum.app"
+ exit -127
+fi
+
+mkdir -p ~/bin
+
+if ! which ${genisoimage} > /dev/null 2>&1; then
+ mkdir -p /tmp/electrum-macos
+ cd /tmp/electrum-macos
+ info "Downloading cdrkit $cdrkit_version"
+ wget -nc ${cdrkit_download_path}/${cdrkit_file_name}
+ tar xvf ${cdrkit_file_name}
+
+ info "Patching genisoimage"
+ cd cdrkit-${cdrkit_version}
+ patch -p1 <$CONTRIB/osx/cdrkit-deterministic.patch
+
+ info "Building genisoimage"
+ cmake . -Wno-dev
+ make genisoimage
+ cp genisoimage/genisoimage ~/bin/${genisoimage}
+fi
+
+if ! which dmg > /dev/null 2>&1; then
+ mkdir -p /tmp/electrum-macos
+ cd /tmp/electrum-macos
+ info "Downloading libdmg"
+ LD_PRELOAD= git clone ${libdmg_url}
+ cd libdmg-hfsplus
+ info "Building libdmg"
+ cmake .
+ make
+ cp dmg/dmg ~/bin
+fi
+
+${genisoimage} -version || fail "Unable to install genisoimage"
+dmg - || fail "Unable to install libdmg"
+
+plist=$1/Contents/Info.plist
+test -f "$plist" || fail "Info.plist not found"
+VERSION=$(grep -1 ShortVersionString $plist | tail -1 | gawk 'match($0, /(.*)<\/string>/, a) {print a[1]}')
+echo $VERSION
+
+rm -rf /tmp/electrum-macos/image > /dev/null 2>&1
+mkdir /tmp/electrum-macos/image/
+cp -r $1 /tmp/electrum-macos/image/
+
+build_dir=$(dirname "$1")
+test -n "$build_dir" -a -d "$build_dir" || exit
+cd $build_dir
+
+${genisoimage} \
+ -no-cache-inodes \
+ -D \
+ -l \
+ -probe \
+ -V "Electrum" \
+ -no-pad \
+ -r \
+ -dir-mode 0755 \
+ -apple \
+ -o Electrum_uncompressed.dmg \
+ /tmp/electrum-macos/image || fail "Unable to create uncompressed dmg"
+
+dmg dmg Electrum_uncompressed.dmg electrum-$VERSION.dmg || fail "Unable to create compressed dmg"
+rm Electrum_uncompressed.dmg
+
+echo "Done."
+sha256sum electrum-$VERSION.dmg
diff --git a/contrib/osx/pyinstaller.spec b/contrib/osx/pyinstaller.spec
new file mode 100644
index 000000000000..682807d1eeef
--- /dev/null
+++ b/contrib/osx/pyinstaller.spec
@@ -0,0 +1,144 @@
+# -*- mode: python -*-
+import sys
+import os
+from typing import TYPE_CHECKING
+
+from PyInstaller.utils.hooks import collect_data_files, collect_submodules, collect_dynamic_libs, copy_metadata
+
+if TYPE_CHECKING:
+ from PyInstaller.building.build_main import Analysis, PYZ, EXE, BUNDLE
+
+
+PACKAGE_NAME='Electrum.app'
+PYPKG='electrum'
+MAIN_SCRIPT='run_electrum'
+PROJECT_ROOT = os.path.abspath(".")
+ICONS_FILE=f"{PROJECT_ROOT}/{PYPKG}/gui/icons/electrum.icns"
+
+
+VERSION = os.environ.get("ELECTRUM_VERSION")
+if not VERSION:
+ raise Exception('no version')
+
+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(f"{PYPKG}.plugins")
+
+
+binaries = []
+# Workaround for "Retro Look":
+binaries += [b for b in collect_dynamic_libs('PyQt6') if 'macstyle' in b[0]]
+# add libsecp256k1, libusb, etc:
+binaries += [(f"{PROJECT_ROOT}/{PYPKG}/*.dylib", ".")]
+
+
+datas = [
+ (f"{PROJECT_ROOT}/{PYPKG}/*.json", PYPKG),
+ (f"{PROJECT_ROOT}/{PYPKG}/lnwire/*.csv", f"{PYPKG}/lnwire"),
+ (f"{PROJECT_ROOT}/{PYPKG}/wordlist/english.txt", f"{PYPKG}/wordlist"),
+ (f"{PROJECT_ROOT}/{PYPKG}/wordlist/slip39.txt", f"{PYPKG}/wordlist"),
+ (f"{PROJECT_ROOT}/{PYPKG}/chains", f"{PYPKG}/chains"),
+ (f"{PROJECT_ROOT}/{PYPKG}/locale", f"{PYPKG}/locale"),
+ (f"{PROJECT_ROOT}/{PYPKG}/plugins", f"{PYPKG}/plugins"),
+ (f"{PROJECT_ROOT}/{PYPKG}/gui/icons", f"{PYPKG}/gui/icons"),
+ (f"{PROJECT_ROOT}/{PYPKG}/gui/fonts", f"{PYPKG}/gui/fonts"),
+]
+datas += collect_data_files(f"{PYPKG}.plugins")
+datas += collect_data_files('trezorlib') # TODO is this needed? and same question for other hww libs
+datas += collect_data_files('safetlib')
+datas += collect_data_files('ckcc')
+datas += collect_data_files('bitbox02')
+
+# some deps rely on importlib metadata
+datas += copy_metadata('slip10') # from trezor->slip10
+datas += copy_metadata('trezor')
+
+# Exclude parts of Qt that we never use. Reduces binary size by tens of MBs. see #4815
+excludes = [
+ "PyQt6.QtBluetooth",
+ "PyQt6.QtDesigner",
+ "PyQt6.QtNfc",
+ "PyQt6.QtPositioning",
+ "PyQt6.QtQml",
+ "PyQt6.QtQuick",
+ "PyQt6.QtQuick3D",
+ "PyQt6.QtQuickWidgets",
+ "PyQt6.QtRemoteObjects",
+ "PyQt6.QtSensors",
+ "PyQt6.QtSerialPort",
+ "PyQt6.QtSpatialAudio",
+ "PyQt6.QtSql",
+ "PyQt6.QtTest",
+ "PyQt6.QtTextToSpeech",
+ "PyQt6.QtWebChannel",
+ "PyQt6.QtWebSockets",
+ "PyQt6.QtXml",
+ # "PyQt6.QtNetwork", # needed by QtMultimedia. kinda weird but ok.
+]
+
+# 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([f"{PROJECT_ROOT}/{MAIN_SCRIPT}",
+ f"{PROJECT_ROOT}/{PYPKG}/gui/qt/main_window.py",
+ f"{PROJECT_ROOT}/{PYPKG}/gui/qt/qrreader/qtmultimedia/camera_dialog.py",
+ f"{PROJECT_ROOT}/{PYPKG}/gui/text.py",
+ f"{PROJECT_ROOT}/{PYPKG}/util.py",
+ f"{PROJECT_ROOT}/{PYPKG}/wallet.py",
+ f"{PROJECT_ROOT}/{PYPKG}/simple_config.py",
+ f"{PROJECT_ROOT}/{PYPKG}/bitcoin.py",
+ f"{PROJECT_ROOT}/{PYPKG}/dnssec.py",
+ f"{PROJECT_ROOT}/{PYPKG}/commands.py",
+ ],
+ binaries=binaries,
+ datas=datas,
+ hiddenimports=hiddenimports,
+ hookspath=[],
+ excludes=excludes,
+ )
+
+
+# http://stackoverflow.com/questions/19055089/pyinstaller-onefile-warning-pyconfig-h-when-importing-scipy-or-scipy-signal
+for d in a.datas:
+ if 'pyconfig' in d[0]:
+ a.datas.remove(d)
+ break
+
+
+pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
+
+exe = EXE(
+ pyz,
+ a.scripts,
+ exclude_binaries=True,
+ name=MAIN_SCRIPT,
+ debug=False,
+ strip=False,
+ upx=True,
+ icon=ICONS_FILE,
+ console=False,
+ target_arch='x86_64', # TODO investigate building 'universal2'
+)
+
+app = BUNDLE(
+ exe,
+ a.binaries,
+ a.zipfiles,
+ a.datas,
+ version=VERSION,
+ name=PACKAGE_NAME,
+ icon=ICONS_FILE,
+ bundle_identifier=None,
+ info_plist={
+ 'NSHighResolutionCapable': 'True',
+ 'NSSupportsAutomaticGraphicsSwitching': 'True',
+ 'CFBundleURLTypes':
+ [{
+ 'CFBundleURLName': 'bitcoin',
+ 'CFBundleURLSchemes': ['bitcoin', 'lightning', 'lnurlp', 'lnurlw', ],
+ }],
+ 'LSMinimumSystemVersion': '11',
+ 'NSCameraUsageDescription': 'Electrum would like to access the camera to scan for QR codes',
+ },
+)
diff --git a/contrib/osx/sign_osx.sh b/contrib/osx/sign_osx.sh
new file mode 100755
index 000000000000..62730f932445
--- /dev/null
+++ b/contrib/osx/sign_osx.sh
@@ -0,0 +1,79 @@
+#!/usr/bin/env bash
+
+set -e
+
+security -v unlock-keychain login.keychain
+
+
+PACKAGE=Electrum
+
+
+. "$(dirname "$0")/../build_tools_util.sh"
+
+
+CONTRIB_OSX="$(dirname "$(realpath "$0")")"
+CONTRIB="$CONTRIB_OSX/.."
+PROJECT_ROOT="$CONTRIB/.."
+CACHEDIR="$CONTRIB_OSX/.cache"
+
+
+cd "$PROJECT_ROOT"
+
+
+# Code Signing: See https://developer.apple.com/library/archive/documentation/Security/Conceptual/CodeSigningGuide/Procedures/Procedures.html
+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
+ set +e
+ codesign -s "$CODESIGN_CERT" --dryrun -f ./CODESIGN_TEST > /dev/null 2>&1
+ res=$?
+ set -e
+ rm -f ./CODESIGN_TEST
+ if ((res)); then
+ fail "Code signing identity \"$CODESIGN_CERT\" appears to be invalid."
+ fi
+ unset res
+ info "Code signing enabled using identity \"$CODESIGN_CERT\""
+else
+ fail "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}"
+}
+
+# note: no --dirty, as we have dirtied electrum/locale/ ourselves.
+VERSION=$(git describe --tags --always)
+
+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"
diff --git a/contrib/print_electrum_version.py b/contrib/print_electrum_version.py
new file mode 100755
index 000000000000..0cce03af8d3f
--- /dev/null
+++ b/contrib/print_electrum_version.py
@@ -0,0 +1,31 @@
+#!/usr/bin/python3
+# For usage in shell, to get the version of electrum, without needing electrum installed.
+# usage: ./print_electrum_version.py []
+#
+# For example:
+# $ VERSION=$("$CONTRIB"/print_electrum_version.py)
+# instead of
+# $ VERSION=$(python3 -c "import electrum; print(electrum.version.ELECTRUM_VERSION)")
+
+import importlib.util
+import os
+import sys
+
+
+if __name__ == '__main__':
+ if len(sys.argv) >= 2:
+ attr_name = sys.argv[1]
+ else:
+ attr_name = "ELECTRUM_VERSION"
+
+ project_root = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
+ version_file_path = os.path.join(project_root, "electrum", "version.py")
+
+ # load version.py; needlessly complicated alternative to "imp.load_source":
+ version_spec = importlib.util.spec_from_file_location('version', version_file_path)
+ version_module = version = importlib.util.module_from_spec(version_spec)
+ version_spec.loader.exec_module(version_module)
+
+ attr_val = getattr(version, attr_name)
+ print(attr_val, file=sys.stdout)
+
diff --git a/contrib/release.sh b/contrib/release.sh
new file mode 100755
index 000000000000..6d6bcc260836
--- /dev/null
+++ b/contrib/release.sh
@@ -0,0 +1,333 @@
+#!/bin/bash
+#
+# This script is used for stage 1 of the release process. It operates exclusively on the airlock.
+# This script, for the RELEASEMANAGER (RM):
+# - builds and uploads all binaries to airlock,
+# - assumes all keys are available, and signs everything
+# This script, for other builders:
+# - builds all reproducible binaries,
+# - downloads binaries built by the release manager (from airlock if SFTPUSER, else from website),
+# compares and signs them,
+# - and then uploads sigs (if SFTPUSER), else they can be submitted as PR to spesmilo/electrum-signatures
+# Note: the .dmg should be built separately beforehand and copied into dist/
+# (as it is built on a separate machine)
+#
+#
+# env vars:
+# - ELECBUILD_NOCACHE: if set, forces rebuild of docker images
+#
+# "uploadserver" is set in /etc/hosts
+#
+# Note: steps before doing a new release:
+# - update locale:
+# 1. cd /opt/electrum-locale && ./update.py && git push
+# 2. cd to the submodule dir, and git pull
+# 3. cd .. && git push
+# - update RELEASE-NOTES and version.py
+# - $ git tag -s "$VERSION" -m "$VERSION"
+# - $ git push "$REMOTE_ORIGIN" tag "$VERSION"
+#
+# -----
+# Then, typical release flow:
+# - RM runs release.sh
+# - Another SFTPUSER BUILDER runs `$ ./release.sh`
+# - now airlock contains new binaries and two sigs for each
+# - deploy.sh will verify sigs and move binaries across airlock
+# - new binaries are now publicly available on uploadserver, but not linked from website yet
+# - other BUILDERS can now also try to reproduce binaries and open PRs with sigs against spesmilo/electrum-signatures
+# - these PRs can get merged as they come
+# - run add_cosigner
+# - after some time, RM can run release_www.sh to create and commit website-update
+# - then run WWW_DIR/publish.sh to update website
+# - at least two people need to run WWW_DIR/publish.sh
+#
+
+set -e
+
+PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/.."
+CONTRIB="$PROJECT_ROOT/contrib"
+
+cd "$PROJECT_ROOT"
+
+. "$CONTRIB"/build_tools_util.sh
+
+# rm -rf dist/*
+# rm -f .buildozer
+
+GPGUSER=$1
+if [ -z "$GPGUSER" ]; then
+ fail "usage: $0 gpg_username"
+fi
+
+RELEASEMANAGER=""
+if [ "$GPGUSER" == "ThomasV" ]; then
+ PUBKEY="--local-user 6694D8DE7BE8EE5631BED9502BD5824B7F9470E6"
+ export SSHUSER=thomasv
+ RELEASEMANAGER=1
+elif [ "$GPGUSER" == "sombernight_releasekey" ]; then
+ PUBKEY="--local-user 0EEDCFD5CAFB459067349B23CA9EEEC43DF911DC"
+ export SSHUSER=sombernight
+else
+ warn "unexpected GPGUSER=$GPGUSER"
+ PUBKEY=""
+ export SSHUSER=""
+fi
+
+
+if [ ! -z "$RELEASEMANAGER" ] ; then
+ echo -n "Code signing passphrase:"
+ read -s password
+ # tests password against keystore
+ keytool -list -storepass $password
+ # the same password is used for windows signing
+ export WIN_SIGNING_PASSWORD=$password
+fi
+
+
+VERSION=$("$CONTRIB"/print_electrum_version.py)
+info "VERSION: $VERSION"
+REV=$(git describe --tags)
+info "REV: $REV"
+COMMIT=$(git rev-parse HEAD)
+
+export ELECBUILD_COMMIT="${COMMIT}^{commit}"
+
+
+git_status=$(git status --porcelain)
+if [ ! -z "$git_status" ]; then
+ echo "$git_status"
+ fail "git repo not clean, aborting"
+fi
+
+set -x
+
+# create tarball
+tarball="Electrum-$VERSION.tar.gz"
+if test -f "dist/$tarball"; then
+ info "file exists: $tarball"
+else
+ ./contrib/build-linux/sdist/build.sh
+fi
+
+# create source-only tarball
+srctarball="Electrum-sourceonly-$VERSION.tar.gz"
+if test -f "dist/$srctarball"; then
+ info "file exists: $srctarball"
+else
+ OMIT_UNCLEAN_FILES=1 ./contrib/build-linux/sdist/build.sh
+fi
+
+# appimage
+appimage="electrum-$REV-x86_64.AppImage"
+if test -f "dist/$appimage"; then
+ info "file exists: $appimage"
+else
+ ./contrib/build-linux/appimage/build.sh
+fi
+
+
+# windows
+win1="electrum-$REV.exe"
+win2="electrum-$REV-portable.exe"
+win3="electrum-$REV-setup.exe"
+if test -f "dist/$win1"; then
+ info "file exists: $win1"
+else
+ pushd .
+ if test -f "contrib/build-wine/dist/$win1"; then
+ info "unsigned file exists: $win1"
+ else
+ ./contrib/build-wine/build.sh
+ fi
+ cd contrib/build-wine/
+ if [ ! -z "$RELEASEMANAGER" ] ; then
+ ./sign.sh
+ cp ./signed/*.exe "$PROJECT_ROOT/dist/"
+ else
+ cp ./dist/*.exe "$PROJECT_ROOT/dist/"
+ fi
+ popd
+fi
+
+# android
+apk1="Electrum-$VERSION-armeabi-v7a-release.apk"
+apk2="Electrum-$VERSION-arm64-v8a-release.apk"
+apk3="Electrum-$VERSION-x86_64-release.apk"
+for arch in armeabi-v7a arm64-v8a x86_64
+do
+ apk="Electrum-$VERSION-$arch-release.apk"
+ apk_unsigned="Electrum-$VERSION-$arch-release-unsigned.apk"
+ if test -f "dist/$apk"; then
+ info "file exists: $apk"
+ else
+ info "file does not exists: $apk"
+ if [ ! -z "$RELEASEMANAGER" ] ; then
+ ./contrib/android/build.sh qml $arch release $password
+ else
+ if test -f "dist/$apk_unsigned"; then
+ # has already been built separately before
+ info "found unsigned: $apk_unsigned"
+ else
+ ./contrib/android/build.sh qml $arch release-unsigned
+ fi
+ mv "dist/$apk_unsigned" "dist/$apk"
+ fi
+ fi
+done
+
+# the macos binary is built on a separate machine.
+# the file that needs to be copied over is the codesigned release binary (regardless of builder role)
+dmg="electrum-$VERSION.dmg"
+if ! test -f "dist/$dmg"; then
+ if [ ! -z "$RELEASEMANAGER" ] ; then # RM
+ fail "dmg is missing, aborting. Please build and codesign the dmg on a mac and copy it over."
+ else # other builders
+ fail "dmg is missing, aborting. Please build the unsigned dmg on a mac, compare it with file built by RM, and if matches, copy RM's dmg."
+ fi
+fi
+
+# now that we have all binaries, if we are the RM, sign them.
+if [ ! -z "$RELEASEMANAGER" ] ; then
+ if test -f "dist/$dmg.asc"; then
+ info "packages are already signed"
+ else
+ info "signing packages"
+ ./contrib/sign_packages "$GPGUSER"
+ fi
+fi
+
+info "build complete"
+sha256sum dist/*.tar.gz
+sha256sum dist/*.AppImage
+sha256sum contrib/build-wine/dist/*.exe
+
+echo -n "proceed (y/n)? "
+read answer
+
+if [ "$answer" != "y" ]; then
+ echo "exit"
+ exit 1
+fi
+
+
+if [ -z "$RELEASEMANAGER" ] ; then
+ # people OTHER THAN release manager.
+ # download binaries built by RM
+ rm -rf "$PROJECT_ROOT/dist/releasemanager"
+ mkdir --parent "$PROJECT_ROOT/dist/releasemanager"
+ cd "$PROJECT_ROOT/dist/releasemanager"
+
+ if [ -z "$SSHUSER" ]; then
+ info "No SFTP access, downloading binaries from website"
+ BASE_URL="https://download.electrum.org/$VERSION"
+ FILES_TO_DOWNLOAD=(
+ "$tarball"
+ "$srctarball"
+ "$appimage"
+ "$win1"
+ "$win2"
+ "$win3"
+ "$apk1"
+ "$apk2"
+ "$apk3"
+ "$dmg"
+ )
+
+ for filename in "${FILES_TO_DOWNLOAD[@]}"; do
+ if [ ! -f "$filename" ]; then
+ info "Downloading $filename..."
+ wget -q "$BASE_URL/$filename" -O "$filename" || fail "Failed to download $filename"
+ else
+ info "File already exists: $filename"
+ fi
+ done
+ else
+ # TODO check somehow that RM had finished uploading
+ sftp -oBatchMode=no -b - "$SSHUSER@uploadserver" <<-EOF
+ cd electrum-downloads-airlock
+ cd "$VERSION"
+ mget *
+ bye
+EOF
+ fi
+
+ # check we have each binary
+ test -f "$tarball" || fail "tarball not found among sftp downloads"
+ test -f "$srctarball" || fail "srctarball not found among sftp downloads"
+ test -f "$appimage" || fail "appimage not found among sftp downloads"
+ test -f "$win1" || fail "win1 not found among sftp downloads"
+ test -f "$win2" || fail "win2 not found among sftp downloads"
+ test -f "$win3" || fail "win3 not found among sftp downloads"
+ test -f "$apk1" || fail "apk1 not found among sftp downloads"
+ test -f "$apk2" || fail "apk2 not found among sftp downloads"
+ test -f "$apk3" || fail "apk3 not found among sftp downloads"
+ test -f "$dmg" || fail "dmg not found among sftp downloads"
+ test -f "$PROJECT_ROOT/dist/$tarball" || fail "tarball not found among built files"
+ test -f "$PROJECT_ROOT/dist/$srctarball" || fail "srctarball not found among built files"
+ test -f "$PROJECT_ROOT/dist/$appimage" || fail "appimage not found among built files"
+ test -f "$CONTRIB/build-wine/dist/$win1" || fail "win1 not found among built files"
+ test -f "$CONTRIB/build-wine/dist/$win2" || fail "win2 not found among built files"
+ test -f "$CONTRIB/build-wine/dist/$win3" || fail "win3 not found among built files"
+ test -f "$PROJECT_ROOT/dist/$apk1" || fail "apk1 not found among built files"
+ test -f "$PROJECT_ROOT/dist/$apk2" || fail "apk2 not found among built files"
+ test -f "$PROJECT_ROOT/dist/$apk3" || fail "apk3 not found among built files"
+ test -f "$PROJECT_ROOT/dist/$dmg" || fail "dmg not found among built files"
+ # compare downloaded binaries against ones we built
+ cmp --silent "$tarball" "$PROJECT_ROOT/dist/$tarball" || fail "files are different. tarball."
+ cmp --silent "$srctarball" "$PROJECT_ROOT/dist/$srctarball" || fail "files are different. srctarball."
+ cmp --silent "$appimage" "$PROJECT_ROOT/dist/$appimage" || fail "files are different. appimage."
+ rm -rf "$CONTRIB/build-wine/signed/" && mkdir --parents "$CONTRIB/build-wine/signed/"
+ cp -f "$win1" "$win2" "$win3" "$CONTRIB/build-wine/signed/"
+ "$CONTRIB/build-wine/unsign.sh" || fail "files are different. windows."
+ "$CONTRIB/android/apkdiff.py" "$apk1" "$PROJECT_ROOT/dist/$apk1" || fail "files are different. android."
+ "$CONTRIB/android/apkdiff.py" "$apk2" "$PROJECT_ROOT/dist/$apk2" || fail "files are different. android."
+ "$CONTRIB/android/apkdiff.py" "$apk3" "$PROJECT_ROOT/dist/$apk3" || fail "files are different. android."
+ cmp --silent "$dmg" "$PROJECT_ROOT/dist/$dmg" || fail "files are different. macos."
+ # all files matched. sign them.
+ rm -rf "$PROJECT_ROOT/dist/sigs/"
+ mkdir --parents "$PROJECT_ROOT/dist/sigs/"
+ for fname in "$tarball" "$srctarball" "$appimage" "$win1" "$win2" "$win3" "$apk1" "$apk2" "$apk3" "$dmg" ; do
+ signame="$fname.$GPGUSER.asc"
+ gpg --sign --armor --detach $PUBKEY --output "$PROJECT_ROOT/dist/sigs/$signame" "$fname"
+ done
+
+ if [ -z "$SSHUSER" ]; then
+ info "Signing successfully, now open a pull request with your signatures to spesmilo/electrum-signatures"
+ exit 0
+ else
+ # upload sigs
+ ELECBUILD_UPLOADFROM="$PROJECT_ROOT/dist/sigs/" "$CONTRIB/upload.sh"
+ fi
+
+else
+ # ONLY release manager
+
+ cd "$PROJECT_ROOT"
+
+ # check we have each binary
+ test -f "$PROJECT_ROOT/dist/$tarball" || fail "tarball not found among built files"
+ test -f "$PROJECT_ROOT/dist/$srctarball" || fail "srctarball not found among built files"
+ test -f "$PROJECT_ROOT/dist/$appimage" || fail "appimage not found among built files"
+ test -f "$PROJECT_ROOT/dist/$win1" || fail "win1 not found among built files"
+ test -f "$PROJECT_ROOT/dist/$win2" || fail "win2 not found among built files"
+ test -f "$PROJECT_ROOT/dist/$win3" || fail "win3 not found among built files"
+ test -f "$PROJECT_ROOT/dist/$apk1" || fail "apk1 not found among built files"
+ test -f "$PROJECT_ROOT/dist/$apk2" || fail "apk2 not found among built files"
+ test -f "$PROJECT_ROOT/dist/$apk3" || fail "apk3 not found among built files"
+ test -f "$PROJECT_ROOT/dist/$dmg" || fail "dmg not found among built files"
+
+ if [ "$REV" != "$VERSION" ]; then
+ fail "versions differ, not uploading"
+ fi
+
+ # upload the files
+ ./contrib/upload.sh
+
+fi
+
+set +x
+
+info "release.sh finished successfully."
+info "After two people ran release.sh, the binaries will be publicly available on uploadserver."
+info "Then, we wait for additional signers, and run add_cosigner for them."
+info "Finally, release_www.sh needs to be run, for the website to be updated."
diff --git a/contrib/release_www.sh b/contrib/release_www.sh
new file mode 100755
index 000000000000..404bb215f08f
--- /dev/null
+++ b/contrib/release_www.sh
@@ -0,0 +1,72 @@
+#!/bin/bash
+#
+# env vars:
+# - WWW_DIR: path to "electrum-web" git clone
+# - for signing the version announcement file:
+# - ELECTRUM_SIGNING_ADDRESS (required)
+# - ELECTRUM_SIGNING_WALLET (required)
+#
+
+set -e
+
+PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/.."
+CONTRIB="$PROJECT_ROOT/contrib"
+
+cd "$PROJECT_ROOT"
+
+. "$CONTRIB"/build_tools_util.sh
+
+
+echo -n "Remember to run add_cosigner to add any additional sigs. Continue (y/n)? "
+read answer
+if [ "$answer" != "y" ]; then
+ echo "exit"
+ exit 1
+fi
+
+
+if [ -z "$WWW_DIR" ] ; then
+ WWW_DIR=/opt/electrum-web
+fi
+
+if [ -z "$ELECTRUM_SIGNING_WALLET" ] || [ -z "$ELECTRUM_SIGNING_ADDRESS" ]; then
+ echo "You need to set env vars ELECTRUM_SIGNING_WALLET and ELECTRUM_SIGNING_ADDRESS!"
+ exit 1
+fi
+
+VERSION=$("$CONTRIB"/print_electrum_version.py)
+info "VERSION: $VERSION"
+
+ANDROID_VERSIONCODE_NULLARCH=$("$CONTRIB"/android/get_apk_versioncode.py "null")
+# ^ note: should parse as an integer in the final json
+info "ANDROID_VERSIONCODE_NULLARCH: $ANDROID_VERSIONCODE_NULLARCH"
+
+set -x
+
+info "updating www repo"
+./contrib/make_download "$WWW_DIR"
+info "signing the version announcement file"
+sig=$(./run_electrum -o signmessage "$ELECTRUM_SIGNING_ADDRESS" "$VERSION" -w "$ELECTRUM_SIGNING_WALLET")
+# note: the contents of "extradata" are currently not signed. We could add another field, extradata_sigs,
+# containing signature(s) for "extradata". extradata, being json, would have to be canonically
+# serialized before signing.
+cat < "$WWW_DIR"/version
+{
+ "version": "$VERSION",
+ "signatures": {"$ELECTRUM_SIGNING_ADDRESS": "$sig"},
+ "extradata": {
+ "android_versioncode_nullarch": $ANDROID_VERSIONCODE_NULLARCH
+ }
+}
+EOF
+
+# push changes to website repo
+pushd "$WWW_DIR"
+git diff
+git commit -a -m "version $VERSION"
+git push
+popd
+
+
+info "release_www.sh finished successfully."
+info "now you should run WWW_DIR/publish.sh to sign the website commit and upload signature"
diff --git a/contrib/requirements.txt b/contrib/requirements.txt
deleted file mode 100644
index 52fada94ec89..000000000000
--- a/contrib/requirements.txt
+++ /dev/null
@@ -1,14 +0,0 @@
-certifi==2017.11.5
-chardet==3.0.4
-dnspython==1.15.0
-ecdsa==0.13
-idna==2.6
-jsonrpclib-pelix==0.3.1
-pbkdf2==1.3
-protobuf==3.5.0.post1
-pyaes==1.6.1
-PySocks==1.6.7
-qrcode==5.3
-requests==2.18.4
-six==1.11.0
-urllib3==1.22
diff --git a/contrib/requirements/ghost.txt b/contrib/requirements/ghost.txt
new file mode 100644
index 000000000000..f506ac04724d
--- /dev/null
+++ b/contrib/requirements/ghost.txt
@@ -0,0 +1,5 @@
+click==8.3.1
+construct==2.10.70
+construct-classes==0.2.2
+platformdirs==4.9.4
+keyring==25.7.0
diff --git a/contrib/requirements/requirements-binaries-mac.txt b/contrib/requirements/requirements-binaries-mac.txt
new file mode 100644
index 000000000000..ab8bd5f33a87
--- /dev/null
+++ b/contrib/requirements/requirements-binaries-mac.txt
@@ -0,0 +1,7 @@
+# Qt 6.8 would require macOS 12+, 6.7 still supports macOS 11
+# Qt 6.7 has issue "No QtMultimedia backends found." (i.e. camera does not work)
+# PyQt6-Qt6==6.6.3 segfaults with "illegal hardware instruction"
+PyQt6<6.7
+PyQt6-Qt6<6.7,!=6.6.3
+
+cryptography>=2.6
diff --git a/contrib/requirements/requirements-binaries.txt b/contrib/requirements/requirements-binaries.txt
new file mode 100644
index 000000000000..b410896805ee
--- /dev/null
+++ b/contrib/requirements/requirements-binaries.txt
@@ -0,0 +1,5 @@
+PyQt6
+
+# we need at least cryptography>=2.1 for electrum.crypto,
+# and at least cryptography>=2.6 for dnspython[DNSSEC]
+cryptography>=2.6
diff --git a/contrib/requirements/requirements-build-android.txt b/contrib/requirements/requirements-build-android.txt
new file mode 100644
index 000000000000..e9d272837b19
--- /dev/null
+++ b/contrib/requirements/requirements-build-android.txt
@@ -0,0 +1,22 @@
+pip
+setuptools
+wheel
+
+# needed by buildozer:
+pexpect
+sh
+# some p4a recipes don't work with cython 3+
+cython<3.0
+
+# needed by python-for-android:
+appdirs
+# colorama upper bound to avoid needing hatchling
+colorama>=0.3.3,<0.4.6
+jinja2
+sh>=1.10
+pep517
+toml
+
+# needed for the Qt/QML Android GUI:
+# TODO double-check this
+typing-extensions
diff --git a/contrib/requirements/requirements-build-appimage.txt b/contrib/requirements/requirements-build-appimage.txt
new file mode 100644
index 000000000000..ee8b4aa890d9
--- /dev/null
+++ b/contrib/requirements/requirements-build-appimage.txt
@@ -0,0 +1,10 @@
+pip
+setuptools
+wheel
+
+# Note: hidapi requires Cython at build-time (not needed at runtime).
+# For reproducible builds, the version of Cython must be pinned down.
+# The pinned Cython must be installed before hidapi is built;
+# otherwise when installing hidapi, pip just downloads the latest Cython.
+# see https://github.com/spesmilo/electrum/issues/5859
+Cython>=0.27
\ No newline at end of file
diff --git a/contrib/requirements/requirements-build-base.txt b/contrib/requirements/requirements-build-base.txt
new file mode 100644
index 000000000000..5bfea96fe4fa
--- /dev/null
+++ b/contrib/requirements/requirements-build-base.txt
@@ -0,0 +1,30 @@
+# This file contains build-time dependencies needed to build other higher level build-time dependencies
+# and runtime dependencies.
+# For reproducibility, some build-time deps, most notably "wheel", need to be pinned. (see #7640)
+# By default, when doing e.g. "pip install", pip downloads the latest version of wheel (and setuptools, etc),
+# regardless whether a sufficiently recent version of wheel is already installed locally...
+# The only way I have found to avoid this, is to use the "--no-build-isolation" flag,
+# in which case it becomes our responsibility to install *all* build time deps...
+
+pip
+setuptools
+wheel
+
+# importlib_metadata also needs:
+# https://github.com/python/importlib_metadata/blob/1e2381fe101fd70742a0171e51c1be82aedf519b/pyproject.toml#L2
+setuptools_scm[toml]>=3.4.1
+# from https://github.com/pypa/setuptools-scm/commit/c766df10c18c3c5a6b5741e9f372e193412c0f69 :
+# (but also to avoid the binary wheels introduced in tomli 2.2)
+tomli<=2.0.2
+
+# dnspython also needs:
+# https://github.com/rthalley/dnspython/blob/1a7c14fb6c200be02ef5c2f3bb9fd84b85004459/pyproject.toml#L64
+poetry-core
+
+# typing-extensions also needs:
+# https://github.com/python/typing/blob/a2371460d184c96aab7a69acc47fd059f875e3b4/typing_extensions/pyproject.toml#L3
+flit_core>=3.4,<4
+
+# aio-libs/frozenlist and aio-libs/propcache needs:
+# https://github.com/aio-libs/frozenlist/blob/c28f32d6816ca0fa56a5876e84831c46084bb85d/pyproject.toml#L6
+expandvars
diff --git a/contrib/requirements/requirements-build-mac.txt b/contrib/requirements/requirements-build-mac.txt
new file mode 100644
index 000000000000..5504223d3fe3
--- /dev/null
+++ b/contrib/requirements/requirements-build-mac.txt
@@ -0,0 +1,17 @@
+pip
+setuptools
+wheel
+
+# needed by pyinstaller:
+# fixme: ugly to have to duplicate this here from upstream
+macholib>=1.8
+altgraph
+pyinstaller-hooks-contrib>=2025.2
+packaging>=22.0
+
+# Note: hidapi requires Cython at build-time (not needed at runtime).
+# For reproducible builds, the version of Cython must be pinned down.
+# The pinned Cython must be installed before hidapi is built;
+# otherwise when installing hidapi, pip just downloads the latest Cython.
+# see https://github.com/spesmilo/electrum/issues/5859
+Cython>=0.27
diff --git a/contrib/requirements/requirements-build-wine.txt b/contrib/requirements/requirements-build-wine.txt
new file mode 100644
index 000000000000..80cccba33790
--- /dev/null
+++ b/contrib/requirements/requirements-build-wine.txt
@@ -0,0 +1,11 @@
+pip
+setuptools
+wheel
+
+# needed by pyinstaller:
+# fixme: ugly to have to duplicate this here from upstream
+pefile>=2022.5.30,!=2024.8.26
+altgraph
+pywin32-ctypes>=0.2.1
+pyinstaller-hooks-contrib>=2025.2
+packaging>=22.0
diff --git a/contrib/requirements/requirements-ci.txt b/contrib/requirements/requirements-ci.txt
new file mode 100644
index 000000000000..d9644b580f97
--- /dev/null
+++ b/contrib/requirements/requirements-ci.txt
@@ -0,0 +1,3 @@
+pytest
+coverage
+coveralls
diff --git a/contrib/requirements/requirements-hw.txt b/contrib/requirements/requirements-hw.txt
new file mode 100644
index 000000000000..5dfb0290e667
--- /dev/null
+++ b/contrib/requirements/requirements-hw.txt
@@ -0,0 +1,32 @@
+hidapi
+
+# device plugin: trezor
+trezor[hidapi]>=0.20.1,<0.21
+
+# device plugin: safe_t
+safet>=0.1.5
+
+# device plugin: keepkey
+ecdsa>=0.9
+protobuf>=3.20
+mnemonic>=0.8
+hidapi>=0.7.99.post15
+libusb1>=1.6
+
+# device plugin: ledger
+ledger-bitcoin>=0.4.1,<1.0
+hidapi
+
+# device plugin: coldcard
+ckcc-protocol>=0.7.7
+
+# device plugin: bitbox02
+bitbox02>=7.0.0
+
+# device plugin: jade
+cbor2>=5.4.6,<6.0.0
+pyserial>=3.5.0,<4.0.0
+
+# prefer older urllib3 to avoid needing hatchling
+# (pulled in via trezor -> requests -> urllib3)
+urllib3<2
diff --git a/contrib/requirements/requirements.txt b/contrib/requirements/requirements.txt
new file mode 100644
index 000000000000..e9963c12c95f
--- /dev/null
+++ b/contrib/requirements/requirements.txt
@@ -0,0 +1,20 @@
+qrcode
+protobuf>=3.20
+qdarkstyle>=3.2
+aiorpcx>=0.25.0,<0.26
+aiohttp>=3.11.0,<4.0.0
+aiohttp_socks>=0.9.2
+certifi
+jsonpatch
+electrum_ecc>=0.0.4,<0.1
+electrum_aionostr>=0.1.0,<0.2
+
+# - upper limit to avoid needing hatchling at build-time :/
+# (however newer versions should work at runtime)
+attrs>=20.1.0,<23
+
+# Note that we also need the dnspython[DNSSEC] extra which pulls in cryptography,
+# but as that is not pure-python it cannot be listed in this file!
+# - upper limit to avoid needing hatchling at build-time :/
+# (however newer versions should work at runtime)
+dnspython>=2.2,<2.5
diff --git a/contrib/sign_packages b/contrib/sign_packages
index d11ef5fc31f2..99703372efb5 100755
--- a/contrib/sign_packages
+++ b/contrib/sign_packages
@@ -1,18 +1,12 @@
-#!/usr/bin/python2
-
-import os
-import getpass
+#!/usr/bin/env python3
+import os, sys
if __name__ == '__main__':
-
+ username = sys.argv[1]
os.chdir("dist")
- password = getpass.getpass("Password:")
- for f in os.listdir('.'):
- if f.endswith('asc'):
+ for fname in os.listdir('.'):
+ if fname.endswith('asc'):
continue
- os.system( "gpg --sign --armor --detach --passphrase \"%s\" %s"%(password, f) )
-
+ sig_name = fname + '.' + username + '.asc'
+ os.system(f"gpg --sign --armor --detach --output {sig_name} {fname}")
os.chdir("..")
-
-
-
diff --git a/contrib/trigger_deploy.sh b/contrib/trigger_deploy.sh
new file mode 100755
index 000000000000..63283612aa60
--- /dev/null
+++ b/contrib/trigger_deploy.sh
@@ -0,0 +1,35 @@
+#!/bin/bash
+# Triggers deploy.sh to maybe update the website or move binaries.
+# uploadserver needs to be defined in /etc/hosts
+
+SSHUSER=$1
+TRIGGERVERSION=$2
+if [ -z "$SSHUSER" ] || [ -z "$TRIGGERVERSION" ]; then
+ echo "usage: $0 SSHUSER TRIGGERVERSION"
+ echo "e.g. $0 thomasv 3.0.0"
+ echo "e.g. $0 thomasv website"
+ exit 1
+fi
+set -ex
+cd "$(dirname "$0")"
+
+if [ "$TRIGGERVERSION" == "website" ]; then
+ rm -f trigger_website
+ touch trigger_website
+ echo "uploading file: trigger_website..."
+ sftp -oBatchMode=no -b - "$SSHUSER@uploadserver" << !
+ cd electrum-downloads-airlock
+ mput trigger_website
+ bye
+!
+else
+ rm -f trigger_binaries
+ printf "$TRIGGERVERSION" > trigger_binaries
+ echo "uploading file: trigger_binaries..."
+ sftp -oBatchMode=no -b - "$SSHUSER@uploadserver" << !
+ cd electrum-downloads-airlock
+ mput trigger_binaries
+ bye
+!
+fi
+
diff --git a/contrib/udev/20-hw1.rules b/contrib/udev/20-hw1.rules
new file mode 100644
index 000000000000..99f386b88f05
--- /dev/null
+++ b/contrib/udev/20-hw1.rules
@@ -0,0 +1,8 @@
+# HW.1, Nano
+SUBSYSTEMS=="usb", ATTRS{idVendor}=="2581", ATTRS{idProduct}=="1b7c|2b7c|3b7c|4b7c", TAG+="uaccess", TAG+="udev-acl"
+
+# Blue, NanoS, Aramis, HW.2, Nano X, NanoSP, Stax, Ledger Test,
+SUBSYSTEMS=="usb", ATTRS{idVendor}=="2c97", TAG+="uaccess", TAG+="udev-acl"
+
+# Same, but with hidraw-based library (instead of libusb)
+KERNEL=="hidraw*", ATTRS{idVendor}=="2c97", MODE="0666"
diff --git a/contrib/udev/51-coinkite.rules b/contrib/udev/51-coinkite.rules
new file mode 100644
index 000000000000..442ba87d6518
--- /dev/null
+++ b/contrib/udev/51-coinkite.rules
@@ -0,0 +1,16 @@
+# Linux udev support file.
+#
+# This is a example udev file for HIDAPI devices which changes the permissions
+# to 0666 (world readable/writable) for a specific device on Linux systems.
+#
+# - Copy this file into /etc/udev/rules.d and unplug and re-plug your Coldcard.
+# - Udev does not have to be restarted.
+#
+
+# probably not needed:
+SUBSYSTEMS=="usb", ATTRS{idVendor}=="d13e", ATTRS{idProduct}=="cc10", GROUP="plugdev", MODE="0666"
+
+# required:
+# from
+KERNEL=="hidraw*", ATTRS{idVendor}=="d13e", ATTRS{idProduct}=="cc10", GROUP="plugdev", MODE="0666"
+
diff --git a/contrib/udev/51-hid-digitalbitbox.rules b/contrib/udev/51-hid-digitalbitbox.rules
new file mode 100644
index 000000000000..94c86203afaf
--- /dev/null
+++ b/contrib/udev/51-hid-digitalbitbox.rules
@@ -0,0 +1 @@
+SUBSYSTEM=="usb", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="dbb%n", ATTRS{idVendor}=="03eb", ATTRS{idProduct}=="2402"
diff --git a/contrib/udev/51-safe-t.rules b/contrib/udev/51-safe-t.rules
new file mode 100644
index 000000000000..280692e434ad
--- /dev/null
+++ b/contrib/udev/51-safe-t.rules
@@ -0,0 +1,10 @@
+# Put this file into /usr/lib/udev/rules.d or /etc/udev/rules.d
+
+# Archos Safe-T mini
+SUBSYSTEM=="usb", ATTR{idVendor}=="0e79", ATTR{idProduct}=="6000", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="safe-tr%n"
+KERNEL=="hidraw*", ATTRS{idVendor}=="0e79", ATTRS{idProduct}=="6000", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl"
+
+# Archos Safe-T mini Bootloader
+SUBSYSTEM=="usb", ATTR{idVendor}=="0e79", ATTR{idProduct}=="6001", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="safe-t%n"
+KERNEL=="hidraw*", ATTRS{idVendor}=="0e79", ATTRS{idProduct}=="6001", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl"
+
diff --git a/contrib/udev/51-trezor.rules b/contrib/udev/51-trezor.rules
new file mode 100644
index 000000000000..c0d43b2e0a59
--- /dev/null
+++ b/contrib/udev/51-trezor.rules
@@ -0,0 +1,17 @@
+# Trezor: The Original Hardware Wallet
+# https://trezor.io/
+#
+# Put this file into /etc/udev/rules.d
+#
+# If you are creating a distribution package,
+# put this into /usr/lib/udev/rules.d or /lib/udev/rules.d
+# depending on your distribution
+
+# Trezor
+SUBSYSTEM=="usb", ATTR{idVendor}=="534c", ATTR{idProduct}=="0001", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="trezor%n"
+KERNEL=="hidraw*", ATTRS{idVendor}=="534c", ATTRS{idProduct}=="0001", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl"
+
+# Trezor v2
+SUBSYSTEM=="usb", ATTR{idVendor}=="1209", ATTR{idProduct}=="53c0", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="trezor%n"
+SUBSYSTEM=="usb", ATTR{idVendor}=="1209", ATTR{idProduct}=="53c1", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="trezor%n"
+KERNEL=="hidraw*", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="53c1", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl"
diff --git a/contrib/udev/51-usb-keepkey.rules b/contrib/udev/51-usb-keepkey.rules
new file mode 100644
index 000000000000..6e38213d3850
--- /dev/null
+++ b/contrib/udev/51-usb-keepkey.rules
@@ -0,0 +1,11 @@
+# KeepKey: Your Private Bitcoin Vault
+# http://www.keepkey.com/
+# Put this file into /usr/lib/udev/rules.d or /etc/udev/rules.d
+
+# KeepKey HID Firmware/Bootloader
+SUBSYSTEM=="usb", ATTR{idVendor}=="2b24", ATTR{idProduct}=="0001", MODE="0666", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="keepkey%n"
+KERNEL=="hidraw*", ATTRS{idVendor}=="2b24", ATTRS{idProduct}=="0001", MODE="0666", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl"
+
+# KeepKey WebUSB Firmware/Bootloader
+SUBSYSTEM=="usb", ATTR{idVendor}=="2b24", ATTR{idProduct}=="0002", MODE="0666", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="keepkey%n"
+KERNEL=="hidraw*", ATTRS{idVendor}=="2b24", ATTRS{idProduct}=="0002", MODE="0666", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl"
diff --git a/contrib/udev/52-hid-digitalbitbox.rules b/contrib/udev/52-hid-digitalbitbox.rules
new file mode 100644
index 000000000000..84fe717211d5
--- /dev/null
+++ b/contrib/udev/52-hid-digitalbitbox.rules
@@ -0,0 +1 @@
+KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="03eb", ATTRS{idProduct}=="2402", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="dbbf%n"
diff --git a/contrib/udev/53-hid-bitbox02.rules b/contrib/udev/53-hid-bitbox02.rules
new file mode 100644
index 000000000000..2daffc03bad2
--- /dev/null
+++ b/contrib/udev/53-hid-bitbox02.rules
@@ -0,0 +1 @@
+SUBSYSTEM=="usb", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="bitbox02_%n", ATTRS{idVendor}=="03eb", ATTRS{idProduct}=="2403"
diff --git a/contrib/udev/54-hid-bitbox02.rules b/contrib/udev/54-hid-bitbox02.rules
new file mode 100644
index 000000000000..1b74e4774304
--- /dev/null
+++ b/contrib/udev/54-hid-bitbox02.rules
@@ -0,0 +1 @@
+KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="03eb", ATTRS{idProduct}=="2403", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="bitbox02-%n"
diff --git a/contrib/udev/55-usb-jade.rules b/contrib/udev/55-usb-jade.rules
new file mode 100644
index 000000000000..0e4dfeaa14e1
--- /dev/null
+++ b/contrib/udev/55-usb-jade.rules
@@ -0,0 +1,2 @@
+KERNEL=="ttyUSB*", SUBSYSTEMS=="usb", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="jade%n"
+KERNEL=="ttyACM*", SUBSYSTEMS=="usb", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="55d4", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="jade%n"
diff --git a/contrib/udev/README.md b/contrib/udev/README.md
new file mode 100644
index 000000000000..d72184e9f779
--- /dev/null
+++ b/contrib/udev/README.md
@@ -0,0 +1,26 @@
+# udev rules
+
+This directory contains all of the udev rules for the supported devices
+as retrieved from vendor websites and repositories.
+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://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
+ - `55-usb-jade.rules` (Blockstream Jade): https://github.com/Blockstream/Jade
+
+# Usage
+
+Apply these rules by copying them to `/etc/udev/rules.d/` and notifying `udevadm`.
+Your user will need to be added to the `plugdev` group, which needs to be created if it does not already exist.
+
+```
+$ sudo groupadd plugdev
+$ sudo usermod -aG plugdev $(whoami)
+$ sudo cp contrib/udev/*.rules /etc/udev/rules.d/
+$ sudo udevadm control --reload-rules && sudo udevadm trigger
+```
diff --git a/contrib/upload.sh b/contrib/upload.sh
new file mode 100755
index 000000000000..b161bd21fabe
--- /dev/null
+++ b/contrib/upload.sh
@@ -0,0 +1,42 @@
+#!/bin/bash
+# uploadserver is set in /etc/hosts
+#
+# env vars:
+# - ELECBUILD_UPLOADFROM
+# - SSHUSER
+
+set -ex
+
+PROJECT_ROOT="$(dirname "$(readlink -e "$0")")/.."
+CONTRIB="$PROJECT_ROOT/contrib"
+
+if [ -z "$SSHUSER" ]; then
+ SSHUSER=thomasv
+fi
+
+cd "$PROJECT_ROOT"
+
+VERSION=$("$CONTRIB"/print_electrum_version.py)
+echo "$VERSION"
+
+if [ -z "$ELECBUILD_UPLOADFROM" ]; then
+ cd "$PROJECT_ROOT/dist"
+else
+ cd "$ELECBUILD_UPLOADFROM"
+fi
+
+
+# do not fail sftp if directory exists
+# see https://stackoverflow.com/questions/51437924/bash-shell-sftp-check-if-directory-exists-before-creating
+
+sftp -oBatchMode=no -b - "$SSHUSER@uploadserver" << !
+ cd electrum-downloads-airlock
+ -mkdir "$VERSION"
+ -chmod 777 "$VERSION"
+ cd "$VERSION"
+ -mput *
+ -chmod 444 * # this prevents future re-uploads of same file
+ bye
+!
+
+"$CONTRIB/trigger_deploy.sh" "$SSHUSER" "$VERSION"
diff --git a/electrum b/electrum
deleted file mode 100755
index 1c21e234e934..000000000000
--- a/electrum
+++ /dev/null
@@ -1,436 +0,0 @@
-#!/usr/bin/env python3
-# -*- mode: python -*-
-#
-# Electrum - lightweight Bitcoin client
-# Copyright (C) 2011 thomasv@gitorious
-#
-# Permission is hereby granted, free of charge, to any person
-# obtaining a copy of this software and associated documentation files
-# (the "Software"), to deal in the Software without restriction,
-# including without limitation the rights to use, copy, modify, merge,
-# publish, distribute, sublicense, and/or sell copies of the Software,
-# and to permit persons to whom the Software is furnished to do so,
-# subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be
-# included in all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
-# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
-# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
-# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-# SOFTWARE.
-import os
-import sys
-
-# from https://gist.github.com/tito/09c42fb4767721dc323d
-import threading
-try:
- import jnius
-except:
- jnius = None
-if jnius:
- orig_thread_run = threading.Thread.run
- def thread_check_run(*args, **kwargs):
- try:
- return orig_thread_run(*args, **kwargs)
- finally:
- jnius.detach()
- threading.Thread.run = thread_check_run
-
-script_dir = os.path.dirname(os.path.realpath(__file__))
-is_bundle = getattr(sys, 'frozen', False)
-is_local = not is_bundle and os.path.exists(os.path.join(script_dir, "electrum.desktop"))
-is_android = 'ANDROID_DATA' in os.environ
-
-# move this back to gui/kivy/__init.py once plugins are moved
-os.environ['KIVY_DATA_DIR'] = os.path.abspath(os.path.dirname(__file__)) + '/gui/kivy/data/'
-
-if is_local or is_android:
- sys.path.insert(0, os.path.join(script_dir, 'packages'))
-
-
-def check_imports():
- # pure-python dependencies need to be imported here for pyinstaller
- try:
- import dns
- import pyaes
- import ecdsa
- import requests
- import qrcode
- import pbkdf2
- import google.protobuf
- import jsonrpclib
- except ImportError as e:
- sys.exit("Error: %s. Try 'sudo pip install '"%str(e))
- # the following imports are for pyinstaller
- from google.protobuf import descriptor
- from google.protobuf import message
- from google.protobuf import reflection
- from google.protobuf import descriptor_pb2
- from jsonrpclib import SimpleJSONRPCServer
- # make sure that certificates are here
- assert os.path.exists(requests.utils.DEFAULT_CA_BUNDLE_PATH)
-
-
-if not is_android:
- check_imports()
-
-# load local module as electrum
-if is_local or is_android:
- import imp
- imp.load_module('electrum', *imp.find_module('lib'))
- imp.load_module('electrum_gui', *imp.find_module('gui'))
- imp.load_module('electrum_plugins', *imp.find_module('plugins'))
-
-
-from electrum import bitcoin
-from electrum import SimpleConfig, Network
-from electrum.wallet import Wallet, Imported_Wallet
-from electrum.storage import WalletStorage
-from electrum.util import print_msg, print_stderr, json_encode, json_decode
-from electrum.util import set_verbosity, InvalidPassword, check_www_dir
-from electrum.commands import get_parser, known_commands, Commands, config_variables
-from electrum import daemon
-from electrum import keystore
-from electrum.mnemonic import Mnemonic
-import electrum_plugins
-
-# get password routine
-def prompt_password(prompt, confirm=True):
- import getpass
- password = getpass.getpass(prompt, stream=None)
- if password and confirm:
- password2 = getpass.getpass("Confirm: ")
- if password != password2:
- sys.exit("Error: Passwords do not match.")
- if not password:
- password = None
- return password
-
-
-
-def run_non_RPC(config):
- cmdname = config.get('cmd')
-
- storage = WalletStorage(config.get_wallet_path())
- if storage.file_exists():
- sys.exit("Error: Remove the existing wallet first!")
-
- def password_dialog():
- return prompt_password("Password (hit return if you do not wish to encrypt your wallet):")
-
- if cmdname == 'restore':
- text = config.get('text').strip()
- passphrase = config.get('passphrase', '')
- password = password_dialog() if keystore.is_private(text) else None
- if keystore.is_address_list(text):
- wallet = Imported_Wallet(storage)
- for x in text.split():
- wallet.import_address(x)
- elif keystore.is_private_key_list(text):
- k = keystore.Imported_KeyStore({})
- storage.put('keystore', k.dump())
- storage.put('use_encryption', bool(password))
- wallet = Imported_Wallet(storage)
- for x in text.split():
- wallet.import_private_key(x, password)
- storage.write()
- else:
- if keystore.is_seed(text):
- k = keystore.from_seed(text, passphrase, False)
- elif keystore.is_master_key(text):
- k = keystore.from_master_key(text)
- else:
- sys.exit("Error: Seed or key not recognized")
- if password:
- k.update_password(None, password)
- storage.put('keystore', k.dump())
- storage.put('wallet_type', 'standard')
- storage.put('use_encryption', bool(password))
- storage.write()
- wallet = Wallet(storage)
- if not config.get('offline'):
- network = Network(config)
- network.start()
- wallet.start_threads(network)
- print_msg("Recovering wallet...")
- wallet.synchronize()
- wallet.wait_until_synchronized()
- msg = "Recovery successful" if wallet.is_found() else "Found no history for this wallet"
- else:
- msg = "This wallet was restored offline. It may contain more addresses than displayed."
- print_msg(msg)
-
- elif cmdname == 'create':
- password = password_dialog()
- passphrase = config.get('passphrase', '')
- seed_type = 'segwit' if config.get('segwit') else 'standard'
- seed = Mnemonic('en').make_seed(seed_type)
- k = keystore.from_seed(seed, passphrase, False)
- storage.put('keystore', k.dump())
- storage.put('wallet_type', 'standard')
- wallet = Wallet(storage)
- wallet.update_password(None, password, True)
- wallet.synchronize()
- print_msg("Your wallet generation seed is:\n\"%s\"" % seed)
- print_msg("Please keep it in a safe place; if you lose it, you will not be able to restore your wallet.")
-
- wallet.storage.write()
- print_msg("Wallet saved in '%s'" % wallet.storage.path)
- sys.exit(0)
-
-
-def init_daemon(config_options):
- config = SimpleConfig(config_options)
- storage = WalletStorage(config.get_wallet_path())
- if not storage.file_exists():
- print_msg("Error: Wallet file not found.")
- print_msg("Type 'electrum create' to create a new wallet, or provide a path to a wallet with the -w option")
- sys.exit(0)
- if storage.is_encrypted():
- if config.get('password'):
- password = config.get('password')
- else:
- password = prompt_password('Password:', False)
- if not password:
- print_msg("Error: Password required")
- sys.exit(1)
- else:
- password = None
- config_options['password'] = password
-
-
-def init_cmdline(config_options, server):
- config = SimpleConfig(config_options)
- cmdname = config.get('cmd')
- cmd = known_commands[cmdname]
-
- if cmdname == 'signtransaction' and config.get('privkey'):
- cmd.requires_wallet = False
- cmd.requires_password = False
-
- if cmdname in ['payto', 'paytomany'] and config.get('unsigned'):
- cmd.requires_password = False
-
- if cmdname in ['payto', 'paytomany'] and config.get('broadcast'):
- cmd.requires_network = True
-
- # instanciate wallet for command-line
- storage = WalletStorage(config.get_wallet_path())
-
- if cmd.requires_wallet and not storage.file_exists():
- print_msg("Error: Wallet file not found.")
- print_msg("Type 'electrum create' to create a new wallet, or provide a path to a wallet with the -w option")
- sys.exit(0)
-
- # important warning
- if cmd.name in ['getprivatekeys']:
- print_stderr("WARNING: ALL your private keys are secret.")
- print_stderr("Exposing a single private key can compromise your entire wallet!")
- print_stderr("In particular, DO NOT use 'redeem private key' services proposed by third parties.")
-
- # commands needing password
- if (cmd.requires_wallet and storage.is_encrypted() and server is None)\
- or (cmd.requires_password and (storage.get('use_encryption') or storage.is_encrypted())):
- if config.get('password'):
- password = config.get('password')
- else:
- password = prompt_password('Password:', False)
- if not password:
- print_msg("Error: Password required")
- sys.exit(1)
- else:
- password = None
-
- config_options['password'] = password
-
- if cmd.name == 'password':
- new_password = prompt_password('New password:')
- config_options['new_password'] = new_password
-
- return cmd, password
-
-
-def run_offline_command(config, config_options):
- cmdname = config.get('cmd')
- cmd = known_commands[cmdname]
- password = config_options.get('password')
- if cmd.requires_wallet:
- storage = WalletStorage(config.get_wallet_path())
- if storage.is_encrypted():
- storage.decrypt(password)
- wallet = Wallet(storage)
- else:
- wallet = None
- # check password
- if cmd.requires_password and storage.get('use_encryption'):
- try:
- seed = wallet.check_password(password)
- except InvalidPassword:
- print_msg("Error: This password does not decode this wallet.")
- sys.exit(1)
- if cmd.requires_network:
- print_msg("Warning: running command offline")
- # arguments passed to function
- args = [config.get(x) for x in cmd.params]
- # decode json arguments
- args = list(map(json_decode, args))
- # options
- kwargs = {}
- for x in cmd.options:
- kwargs[x] = (config_options.get(x) if x in ['password', 'new_password'] else config.get(x))
- cmd_runner = Commands(config, wallet, None)
- func = getattr(cmd_runner, cmd.name)
- result = func(*args, **kwargs)
- # save wallet
- if wallet:
- wallet.storage.write()
- return result
-
-def init_plugins(config, gui_name):
- from electrum.plugins import Plugins
- return Plugins(config, is_local or is_android, gui_name)
-
-if __name__ == '__main__':
-
- # on osx, delete Process Serial Number arg generated for apps launched in Finder
- sys.argv = list(filter(lambda x: not x.startswith('-psn'), sys.argv))
-
- # old 'help' syntax
- if len(sys.argv) > 1 and sys.argv[1] == 'help':
- sys.argv.remove('help')
- sys.argv.append('-h')
-
- # read arguments from stdin pipe and prompt
- for i, arg in enumerate(sys.argv):
- if arg == '-':
- if not sys.stdin.isatty():
- sys.argv[i] = sys.stdin.read()
- break
- else:
- raise BaseException('Cannot get argument from stdin')
- elif arg == '?':
- sys.argv[i] = input("Enter argument:")
- elif arg == ':':
- sys.argv[i] = prompt_password('Enter argument (will not echo):', False)
-
- # parse command line
- parser = get_parser()
- args = parser.parse_args()
-
- # config is an object passed to the various constructors (wallet, interface, gui)
- if is_android:
- config_options = {
- 'verbose': True,
- 'cmd': 'gui',
- 'gui': 'kivy',
- }
- else:
- config_options = args.__dict__
- f = lambda key: config_options[key] is not None and key not in config_variables.get(args.cmd, {}).keys()
- config_options = {key: config_options[key] for key in filter(f, config_options.keys())}
- if config_options.get('server'):
- config_options['auto_connect'] = False
-
- config_options['cwd'] = os.getcwd()
-
- # fixme: this can probably be achieved with a runtime hook (pyinstaller)
- if is_bundle and os.path.exists(os.path.join(sys._MEIPASS, 'is_portable')):
- config_options['portable'] = True
-
- if config_options.get('portable'):
- config_options['electrum_path'] = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'electrum_data')
-
- # kivy sometimes freezes when we write to sys.stderr
- set_verbosity(config_options.get('verbose') and config_options.get('gui')!='kivy')
-
- # check uri
- uri = config_options.get('url')
- if uri:
- if not uri.startswith('bitcoin:'):
- print_stderr('unknown command:', uri)
- sys.exit(1)
- config_options['url'] = uri
-
- # todo: defer this to gui
- config = SimpleConfig(config_options)
- cmdname = config.get('cmd')
-
- if config.get('testnet'):
- bitcoin.NetworkConstants.set_testnet()
-
- # run non-RPC commands separately
- if cmdname in ['create', 'restore']:
- run_non_RPC(config)
- sys.exit(0)
-
- if cmdname == 'gui':
- fd, server = daemon.get_fd_or_server(config)
- if fd is not None:
- plugins = init_plugins(config, config.get('gui', 'qt'))
- d = daemon.Daemon(config, fd)
- d.start()
- d.init_gui(config, plugins)
- sys.exit(0)
- else:
- result = server.gui(config_options)
-
- elif cmdname == 'daemon':
- subcommand = config.get('subcommand')
- if subcommand in ['load_wallet']:
- init_daemon(config_options)
-
- if subcommand in [None, 'start']:
- fd, server = daemon.get_fd_or_server(config)
- if fd is not None:
- if subcommand == 'start':
- pid = os.fork()
- if pid:
- print_stderr("starting daemon (PID %d)" % pid)
- sys.exit(0)
- init_plugins(config, 'cmdline')
- d = daemon.Daemon(config, fd)
- d.start()
- if config.get('websocket_server'):
- from electrum import websockets
- websockets.WebSocketServer(config, d.network).start()
- if config.get('requests_dir'):
- check_www_dir(config.get('requests_dir'))
- d.join()
- sys.exit(0)
- else:
- result = server.daemon(config_options)
- else:
- server = daemon.get_server(config)
- if server is not None:
- result = server.daemon(config_options)
- else:
- print_msg("Daemon not running")
- sys.exit(1)
- else:
- # command line
- server = daemon.get_server(config)
- init_cmdline(config_options, server)
- if server is not None:
- result = server.run_cmdline(config_options)
- else:
- cmd = known_commands[cmdname]
- if cmd.requires_network:
- print_msg("Daemon not running; try 'electrum daemon start'")
- sys.exit(1)
- else:
- init_plugins(config, 'cmdline')
- result = run_offline_command(config, config_options)
-
- # print result
- if isinstance(result, str):
- print_msg(result)
- elif type(result) is dict and result.get('error'):
- print_stderr(result.get('error'))
- elif result is not None:
- print_msg(json_encode(result))
- sys.exit(0)
diff --git a/electrum-env b/electrum-env
old mode 100644
new mode 100755
index 42220edab67c..0cd7436148e8
--- a/electrum-env
+++ b/electrum-env
@@ -1,24 +1,39 @@
-#!/bin/bash
+#!/usr/bin/env bash
#
-# This script creates a virtualenv named 'env' and installs all
+# This script creates a virtualenv named 'env' and installs all pinned
# python dependencies before activating the env and running Electrum.
# If 'env' already exists, it is activated and Electrum is started
-# without any installations. Additionally, the PYTHONPATH environment
-# variable is set properly before running Electrum.
+# without any installations (unless the pins have changed).
#
-# python-qt and its dependencies will still need to be installed with
-# your package manager.
+# By default, not all optional dependencies are installed.
+# E.g. for hardware wallet support, do:
+# $ source ./env/bin/activate
+# $ pip install -r contrib/deterministic-build/requirements-hw.txt
+# $ deactivate
-if [ -e ./env/bin/activate ]; then
+set -e
+
+cd "$(dirname "$0")"
+if [ -e ./env/bin/activate ]; then # existing venv
source ./env/bin/activate
-else
- virtualenv env -p `which python3`
+else # create new venv
+ echo "Creating new venv."
+ python3 -m venv env
source ./env/bin/activate
- python3 setup.py install
+ pip install -r contrib/deterministic-build/requirements.txt
+ pip install -r contrib/deterministic-build/requirements-binaries.txt
+ pip install --no-dependencies -e .
+ echo "Done creating venv."
fi
-export PYTHONPATH="/usr/local/lib/python3.5/site-packages:$PYTHONPATH"
-
-./electrum "$@"
+# This might be an old directory and our requirements might have changed in the meantime:
+DEPS_CHANGED_TIME=$(stat --printf %Y contrib/deterministic-build/requirements.txt)
+if [ "$DEPS_CHANGED_TIME" -gt "$(stat --printf %Y env)" ] ; then
+ echo "Detected changed requirements.txt. Updating dependencies now..."
+ pip install -r contrib/deterministic-build/requirements.txt
+ pip install -r contrib/deterministic-build/requirements-binaries.txt
+ touch env
+ echo "Done updating deps."
+fi
-deactivate
+./run_electrum "$@"
diff --git a/electrum.conf.sample b/electrum.conf.sample
deleted file mode 100644
index 60198a1e90dd..000000000000
--- a/electrum.conf.sample
+++ /dev/null
@@ -1,16 +0,0 @@
-# Configuration file for the electrum client
-# Settings defined here are shared across wallets
-#
-# copy this file to /etc/electrum.conf if you want read-only settings
-
-[client]
-server = electrum.novit.ro:50001:t
-proxy = None
-gap_limit = 5
-# booleans use python syntax
-use_change = True
-gui = qt
-num_zeros = 2
-# default transaction fee is in Satoshis
-fee = 10000
-winpos-qt = [799, 226, 877, 435]
diff --git a/electrum.desktop b/electrum.desktop
index 92540ea415e5..8d8d9edbd3ce 100644
--- a/electrum.desktop
+++ b/electrum.desktop
@@ -1,5 +1,6 @@
-# If you want electrum to appear in a linux app launcher ("start menu"), install this by doing:
+# If you want Electrum to appear in a Linux app launcher ("start menu"), install this by doing:
# sudo desktop-file-install electrum.desktop
+# Note: This assumes $HOME/.local/bin is in your $PATH
[Desktop Entry]
Comment=Lightweight Bitcoin Client
@@ -10,8 +11,14 @@ Icon=electrum
Name[en_US]=Electrum Bitcoin Wallet
Name=Electrum Bitcoin Wallet
Categories=Finance;Network;
-StartupNotify=false
+StartupNotify=true
+StartupWMClass=electrum
Terminal=false
Type=Application
-MimeType=x-scheme-handler/bitcoin;
+MimeType=x-scheme-handler/bitcoin;x-scheme-handler/lightning;x-scheme-handler/lnurlp;x-scheme-handler/lnurlw;
+Actions=Testnet;
+Keywords=crypto;currency;BTC
+[Desktop Action Testnet]
+Exec=electrum --testnet %u
+Name=Testnet mode
diff --git a/electrum/__init__.py b/electrum/__init__.py
new file mode 100644
index 000000000000..e94ac78ac9d9
--- /dev/null
+++ b/electrum/__init__.py
@@ -0,0 +1,55 @@
+import sys
+import os
+
+# these are ~duplicated from run_electrum:
+is_bundle = getattr(sys, 'frozen', False)
+is_local = not is_bundle and os.path.exists(os.path.join(os.path.dirname(os.path.dirname(__file__)), "electrum.desktop"))
+
+# when running from source, on Windows, also search for DLLs in inner 'electrum' folder
+if is_local and os.name == 'nt': # fixme: duplicated between main script and __init__.py :(
+ os.add_dll_directory(os.path.dirname(__file__))
+
+
+class GuiImportError(ImportError):
+ pass
+
+
+from .version import ELECTRUM_VERSION
+from .util import format_satoshis
+from .wallet import Wallet
+from .storage import WalletStorage
+from .coinchooser import COIN_CHOOSERS
+from .network import Network, pick_random_server
+from .interface import Interface
+from .simple_config import SimpleConfig
+from . import bitcoin
+from . import transaction
+from . import daemon
+from .transaction import Transaction
+from .plugin import BasePlugin
+from .commands import Commands, known_commands
+from .logging import get_logger
+
+
+__version__ = ELECTRUM_VERSION
+
+_logger = get_logger(__name__)
+
+
+# Ensure that asserts are enabled. For sanity and paranoia, we require this.
+# Code *should not rely* on asserts being enabled. In particular, safety and security checks should
+# always explicitly raise exceptions. However, this rule is mistakenly broken occasionally...
+try:
+ assert False # noqa: B011
+except AssertionError:
+ pass
+else:
+ raise ImportError("Running with asserts disabled. Refusing to continue. Exiting...")
+
+
+# Check that os.urandom works
+import zlib
+length = len(zlib.compress(os.urandom(1000)))
+if length <= 900:
+ raise ImportError("Broken PRNG. Refusing to continue. Exiting...")
+
diff --git a/electrum/_vendor/__init__.py b/electrum/_vendor/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/electrum/_vendor/distutils/LICENSE b/electrum/_vendor/distutils/LICENSE
new file mode 100644
index 000000000000..02a5145f0e38
--- /dev/null
+++ b/electrum/_vendor/distutils/LICENSE
@@ -0,0 +1,279 @@
+A. HISTORY OF THE SOFTWARE
+==========================
+
+Python was created in the early 1990s by Guido van Rossum at Stichting
+Mathematisch Centrum (CWI, see http://www.cwi.nl) in the Netherlands
+as a successor of a language called ABC. Guido remains Python's
+principal author, although it includes many contributions from others.
+
+In 1995, Guido continued his work on Python at the Corporation for
+National Research Initiatives (CNRI, see http://www.cnri.reston.va.us)
+in Reston, Virginia where he released several versions of the
+software.
+
+In May 2000, Guido and the Python core development team moved to
+BeOpen.com to form the BeOpen PythonLabs team. In October of the same
+year, the PythonLabs team moved to Digital Creations, which became
+Zope Corporation. In 2001, the Python Software Foundation (PSF, see
+https://www.python.org/psf/) was formed, a non-profit organization
+created specifically to own Python-related Intellectual Property.
+Zope Corporation was a sponsoring member of the PSF.
+
+All Python releases are Open Source (see http://www.opensource.org for
+the Open Source Definition). Historically, most, but not all, Python
+releases have also been GPL-compatible; the table below summarizes
+the various releases.
+
+ Release Derived Year Owner GPL-
+ from compatible? (1)
+
+ 0.9.0 thru 1.2 1991-1995 CWI yes
+ 1.3 thru 1.5.2 1.2 1995-1999 CNRI yes
+ 1.6 1.5.2 2000 CNRI no
+ 2.0 1.6 2000 BeOpen.com no
+ 1.6.1 1.6 2001 CNRI yes (2)
+ 2.1 2.0+1.6.1 2001 PSF no
+ 2.0.1 2.0+1.6.1 2001 PSF yes
+ 2.1.1 2.1+2.0.1 2001 PSF yes
+ 2.1.2 2.1.1 2002 PSF yes
+ 2.1.3 2.1.2 2002 PSF yes
+ 2.2 and above 2.1.1 2001-now PSF yes
+
+Footnotes:
+
+(1) GPL-compatible doesn't mean that we're distributing Python under
+ the GPL. All Python licenses, unlike the GPL, let you distribute
+ a modified version without making your changes open source. The
+ GPL-compatible licenses make it possible to combine Python with
+ other software that is released under the GPL; the others don't.
+
+(2) According to Richard Stallman, 1.6.1 is not GPL-compatible,
+ because its license has a choice of law clause. According to
+ CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1
+ is "not incompatible" with the GPL.
+
+Thanks to the many outside volunteers who have worked under Guido's
+direction to make these releases possible.
+
+
+B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON
+===============================================================
+
+Python software and documentation are licensed under the
+Python Software Foundation License Version 2.
+
+Starting with Python 3.8.6, examples, recipes, and other code in
+the documentation are dual licensed under the PSF License Version 2
+and the Zero-Clause BSD license.
+
+Some software incorporated into Python is under different licenses.
+The licenses are listed with code falling under that license.
+
+
+PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2
+--------------------------------------------
+
+1. This LICENSE AGREEMENT is between the Python Software Foundation
+("PSF"), and the Individual or Organization ("Licensee") accessing and
+otherwise using this software ("Python") in source or binary form and
+its associated documentation.
+
+2. Subject to the terms and conditions of this License Agreement, PSF hereby
+grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce,
+analyze, test, perform and/or display publicly, prepare derivative works,
+distribute, and otherwise use Python alone or in any derivative version,
+provided, however, that PSF's License Agreement and PSF's notice of copyright,
+i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010,
+2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022 Python Software Foundation;
+All Rights Reserved" are retained in Python alone or in any derivative version
+prepared by Licensee.
+
+3. In the event Licensee prepares a derivative work that is based on
+or incorporates Python or any part thereof, and wants to make
+the derivative work available to others as provided herein, then
+Licensee hereby agrees to include in any such work a brief summary of
+the changes made to Python.
+
+4. PSF is making Python available to Licensee on an "AS IS"
+basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
+IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND
+DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
+FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT
+INFRINGE ANY THIRD PARTY RIGHTS.
+
+5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
+FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
+A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON,
+OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
+
+6. This License Agreement will automatically terminate upon a material
+breach of its terms and conditions.
+
+7. Nothing in this License Agreement shall be deemed to create any
+relationship of agency, partnership, or joint venture between PSF and
+Licensee. This License Agreement does not grant permission to use PSF
+trademarks or trade name in a trademark sense to endorse or promote
+products or services of Licensee, or any third party.
+
+8. By copying, installing or otherwise using Python, Licensee
+agrees to be bound by the terms and conditions of this License
+Agreement.
+
+
+BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0
+-------------------------------------------
+
+BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1
+
+1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an
+office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the
+Individual or Organization ("Licensee") accessing and otherwise using
+this software in source or binary form and its associated
+documentation ("the Software").
+
+2. Subject to the terms and conditions of this BeOpen Python License
+Agreement, BeOpen hereby grants Licensee a non-exclusive,
+royalty-free, world-wide license to reproduce, analyze, test, perform
+and/or display publicly, prepare derivative works, distribute, and
+otherwise use the Software alone or in any derivative version,
+provided, however, that the BeOpen Python License is retained in the
+Software, alone or in any derivative version prepared by Licensee.
+
+3. BeOpen is making the Software available to Licensee on an "AS IS"
+basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
+IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND
+DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
+FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT
+INFRINGE ANY THIRD PARTY RIGHTS.
+
+4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE
+SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS
+AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY
+DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
+
+5. This License Agreement will automatically terminate upon a material
+breach of its terms and conditions.
+
+6. This License Agreement shall be governed by and interpreted in all
+respects by the law of the State of California, excluding conflict of
+law provisions. Nothing in this License Agreement shall be deemed to
+create any relationship of agency, partnership, or joint venture
+between BeOpen and Licensee. This License Agreement does not grant
+permission to use BeOpen trademarks or trade names in a trademark
+sense to endorse or promote products or services of Licensee, or any
+third party. As an exception, the "BeOpen Python" logos available at
+http://www.pythonlabs.com/logos.html may be used according to the
+permissions granted on that web page.
+
+7. By copying, installing or otherwise using the software, Licensee
+agrees to be bound by the terms and conditions of this License
+Agreement.
+
+
+CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1
+---------------------------------------
+
+1. This LICENSE AGREEMENT is between the Corporation for National
+Research Initiatives, having an office at 1895 Preston White Drive,
+Reston, VA 20191 ("CNRI"), and the Individual or Organization
+("Licensee") accessing and otherwise using Python 1.6.1 software in
+source or binary form and its associated documentation.
+
+2. Subject to the terms and conditions of this License Agreement, CNRI
+hereby grants Licensee a nonexclusive, royalty-free, world-wide
+license to reproduce, analyze, test, perform and/or display publicly,
+prepare derivative works, distribute, and otherwise use Python 1.6.1
+alone or in any derivative version, provided, however, that CNRI's
+License Agreement and CNRI's notice of copyright, i.e., "Copyright (c)
+1995-2001 Corporation for National Research Initiatives; All Rights
+Reserved" are retained in Python 1.6.1 alone or in any derivative
+version prepared by Licensee. Alternately, in lieu of CNRI's License
+Agreement, Licensee may substitute the following text (omitting the
+quotes): "Python 1.6.1 is made available subject to the terms and
+conditions in CNRI's License Agreement. This Agreement together with
+Python 1.6.1 may be located on the internet using the following
+unique, persistent identifier (known as a handle): 1895.22/1013. This
+Agreement may also be obtained from a proxy server on the internet
+using the following URL: http://hdl.handle.net/1895.22/1013".
+
+3. In the event Licensee prepares a derivative work that is based on
+or incorporates Python 1.6.1 or any part thereof, and wants to make
+the derivative work available to others as provided herein, then
+Licensee hereby agrees to include in any such work a brief summary of
+the changes made to Python 1.6.1.
+
+4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS"
+basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
+IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND
+DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
+FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT
+INFRINGE ANY THIRD PARTY RIGHTS.
+
+5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
+1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
+A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1,
+OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
+
+6. This License Agreement will automatically terminate upon a material
+breach of its terms and conditions.
+
+7. This License Agreement shall be governed by the federal
+intellectual property law of the United States, including without
+limitation the federal copyright law, and, to the extent such
+U.S. federal law does not apply, by the law of the Commonwealth of
+Virginia, excluding Virginia's conflict of law provisions.
+Notwithstanding the foregoing, with regard to derivative works based
+on Python 1.6.1 that incorporate non-separable material that was
+previously distributed under the GNU General Public License (GPL), the
+law of the Commonwealth of Virginia shall govern this License
+Agreement only as to issues arising under or with respect to
+Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this
+License Agreement shall be deemed to create any relationship of
+agency, partnership, or joint venture between CNRI and Licensee. This
+License Agreement does not grant permission to use CNRI trademarks or
+trade name in a trademark sense to endorse or promote products or
+services of Licensee, or any third party.
+
+8. By clicking on the "ACCEPT" button where indicated, or by copying,
+installing or otherwise using Python 1.6.1, Licensee agrees to be
+bound by the terms and conditions of this License Agreement.
+
+ ACCEPT
+
+
+CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2
+--------------------------------------------------
+
+Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam,
+The Netherlands. All rights reserved.
+
+Permission to use, copy, modify, and distribute this software and its
+documentation for any purpose and without fee is hereby granted,
+provided that the above copyright notice appear in all copies and that
+both that copyright notice and this permission notice appear in
+supporting documentation, and that the name of Stichting Mathematisch
+Centrum or CWI not be used in advertising or publicity pertaining to
+distribution of the software without specific, written prior
+permission.
+
+STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO
+THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
+FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE
+FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+ZERO-CLAUSE BSD LICENSE FOR CODE IN THE PYTHON DOCUMENTATION
+----------------------------------------------------------------------
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
diff --git a/electrum/_vendor/distutils/__init__.py b/electrum/_vendor/distutils/__init__.py
new file mode 100644
index 000000000000..40f054de15f5
--- /dev/null
+++ b/electrum/_vendor/distutils/__init__.py
@@ -0,0 +1,4 @@
+"""(part of) distutils, taken from the cpython standard library
+
+at commit https://github.com/python/cpython/tree/9d38120e335357a3b294277fd5eff0a10e46e043/Lib/distutils
+"""
diff --git a/electrum/_vendor/distutils/version.py b/electrum/_vendor/distutils/version.py
new file mode 100644
index 000000000000..c33bebaed26a
--- /dev/null
+++ b/electrum/_vendor/distutils/version.py
@@ -0,0 +1,347 @@
+#
+# distutils/version.py
+#
+# Implements multiple version numbering conventions for the
+# Python Module Distribution Utilities.
+#
+# $Id$
+#
+
+"""Provides classes to represent module version numbers (one class for
+each style of version numbering). There are currently two such classes
+implemented: StrictVersion and LooseVersion.
+
+Every version number class implements the following interface:
+ * the 'parse' method takes a string and parses it to some internal
+ representation; if the string is an invalid version number,
+ 'parse' raises a ValueError exception
+ * the class constructor takes an optional string argument which,
+ if supplied, is passed to 'parse'
+ * __str__ reconstructs the string that was passed to 'parse' (or
+ an equivalent string -- ie. one that will generate an equivalent
+ version number instance)
+ * __repr__ generates Python code to recreate the version number instance
+ * _cmp compares the current instance with either another instance
+ of the same class or a string (which will be parsed to an instance
+ of the same class, thus must follow the same rules)
+"""
+
+import re
+
+class Version:
+ """Abstract base class for version numbering classes. Just provides
+ constructor (__init__) and reproducer (__repr__), because those
+ seem to be the same for all version numbering classes; and route
+ rich comparisons to _cmp.
+ """
+
+ def __init__ (self, vstring=None):
+ if vstring:
+ self.parse(vstring)
+
+ def __repr__ (self):
+ return "%s ('%s')" % (self.__class__.__name__, str(self))
+
+ def __eq__(self, other):
+ c = self._cmp(other)
+ if c is NotImplemented:
+ return c
+ return c == 0
+
+ def __lt__(self, other):
+ c = self._cmp(other)
+ if c is NotImplemented:
+ return c
+ return c < 0
+
+ def __le__(self, other):
+ c = self._cmp(other)
+ if c is NotImplemented:
+ return c
+ return c <= 0
+
+ def __gt__(self, other):
+ c = self._cmp(other)
+ if c is NotImplemented:
+ return c
+ return c > 0
+
+ def __ge__(self, other):
+ c = self._cmp(other)
+ if c is NotImplemented:
+ return c
+ return c >= 0
+
+
+# Interface for version-number classes -- must be implemented
+# by the following classes (the concrete ones -- Version should
+# be treated as an abstract class).
+# __init__ (string) - create and take same action as 'parse'
+# (string parameter is optional)
+# parse (string) - convert a string representation to whatever
+# internal representation is appropriate for
+# this style of version numbering
+# __str__ (self) - convert back to a string; should be very similar
+# (if not identical to) the string supplied to parse
+# __repr__ (self) - generate Python code to recreate
+# the instance
+# _cmp (self, other) - compare two version numbers ('other' may
+# be an unparsed version string, or another
+# instance of your version class)
+
+
+class StrictVersion (Version):
+
+ """Version numbering for anal retentives and software idealists.
+ Implements the standard interface for version number classes as
+ described above. A version number consists of two or three
+ dot-separated numeric components, with an optional "pre-release" tag
+ on the end. The pre-release tag consists of the letter 'a' or 'b'
+ followed by a number. If the numeric components of two version
+ numbers are equal, then one with a pre-release tag will always
+ be deemed earlier (lesser) than one without.
+
+ The following are valid version numbers (shown in the order that
+ would be obtained by sorting according to the supplied cmp function):
+
+ 0.4 0.4.0 (these two are equivalent)
+ 0.4.1
+ 0.5a1
+ 0.5b3
+ 0.5
+ 0.9.6
+ 1.0
+ 1.0.4a3
+ 1.0.4b1
+ 1.0.4
+
+ The following are examples of invalid version numbers:
+
+ 1
+ 2.7.2.2
+ 1.3.a4
+ 1.3pl1
+ 1.3c4
+
+ The rationale for this version numbering system will be explained
+ in the distutils documentation.
+ """
+
+ version_re = re.compile(r'^(\d+) \. (\d+) (\. (\d+))? ([ab](\d+))?$',
+ re.VERBOSE | re.ASCII)
+
+
+ def parse (self, vstring):
+ match = self.version_re.match(vstring)
+ if not match:
+ raise ValueError("invalid version number '%s'" % vstring)
+
+ (major, minor, patch, prerelease, prerelease_num) = \
+ match.group(1, 2, 4, 5, 6)
+
+ if patch:
+ self.version = tuple(map(int, [major, minor, patch]))
+ else:
+ self.version = tuple(map(int, [major, minor])) + (0,)
+
+ if prerelease:
+ self.prerelease = (prerelease[0], int(prerelease_num))
+ else:
+ self.prerelease = None
+
+
+ def __str__ (self):
+
+ if self.version[2] == 0:
+ vstring = '.'.join(map(str, self.version[0:2]))
+ else:
+ vstring = '.'.join(map(str, self.version))
+
+ if self.prerelease:
+ vstring = vstring + self.prerelease[0] + str(self.prerelease[1])
+
+ return vstring
+
+
+ def _cmp (self, other):
+ if isinstance(other, str):
+ other = StrictVersion(other)
+ elif not isinstance(other, StrictVersion):
+ return NotImplemented
+
+ if self.version != other.version:
+ # numeric versions don't match
+ # prerelease stuff doesn't matter
+ if self.version < other.version:
+ return -1
+ else:
+ return 1
+
+ # have to compare prerelease
+ # case 1: neither has prerelease; they're equal
+ # case 2: self has prerelease, other doesn't; other is greater
+ # case 3: self doesn't have prerelease, other does: self is greater
+ # case 4: both have prerelease: must compare them!
+
+ if (not self.prerelease and not other.prerelease):
+ return 0
+ elif (self.prerelease and not other.prerelease):
+ return -1
+ elif (not self.prerelease and other.prerelease):
+ return 1
+ elif (self.prerelease and other.prerelease):
+ if self.prerelease == other.prerelease:
+ return 0
+ elif self.prerelease < other.prerelease:
+ return -1
+ else:
+ return 1
+ else:
+ assert False, "never get here"
+
+# end class StrictVersion
+
+
+# The rules according to Greg Stein:
+# 1) a version number has 1 or more numbers separated by a period or by
+# sequences of letters. If only periods, then these are compared
+# left-to-right to determine an ordering.
+# 2) sequences of letters are part of the tuple for comparison and are
+# compared lexicographically
+# 3) recognize the numeric components may have leading zeroes
+#
+# The LooseVersion class below implements these rules: a version number
+# string is split up into a tuple of integer and string components, and
+# comparison is a simple tuple comparison. This means that version
+# numbers behave in a predictable and obvious way, but a way that might
+# not necessarily be how people *want* version numbers to behave. There
+# wouldn't be a problem if people could stick to purely numeric version
+# numbers: just split on period and compare the numbers as tuples.
+# However, people insist on putting letters into their version numbers;
+# the most common purpose seems to be:
+# - indicating a "pre-release" version
+# ('alpha', 'beta', 'a', 'b', 'pre', 'p')
+# - indicating a post-release patch ('p', 'pl', 'patch')
+# but of course this can't cover all version number schemes, and there's
+# no way to know what a programmer means without asking him.
+#
+# The problem is what to do with letters (and other non-numeric
+# characters) in a version number. The current implementation does the
+# obvious and predictable thing: keep them as strings and compare
+# lexically within a tuple comparison. This has the desired effect if
+# an appended letter sequence implies something "post-release":
+# eg. "0.99" < "0.99pl14" < "1.0", and "5.001" < "5.001m" < "5.002".
+#
+# However, if letters in a version number imply a pre-release version,
+# the "obvious" thing isn't correct. Eg. you would expect that
+# "1.5.1" < "1.5.2a2" < "1.5.2", but under the tuple/lexical comparison
+# implemented here, this just isn't so.
+#
+# Two possible solutions come to mind. The first is to tie the
+# comparison algorithm to a particular set of semantic rules, as has
+# been done in the StrictVersion class above. This works great as long
+# as everyone can go along with bondage and discipline. Hopefully a
+# (large) subset of Python module programmers will agree that the
+# particular flavour of bondage and discipline provided by StrictVersion
+# provides enough benefit to be worth using, and will submit their
+# version numbering scheme to its domination. The free-thinking
+# anarchists in the lot will never give in, though, and something needs
+# to be done to accommodate them.
+#
+# Perhaps a "moderately strict" version class could be implemented that
+# lets almost anything slide (syntactically), and makes some heuristic
+# assumptions about non-digits in version number strings. This could
+# sink into special-case-hell, though; if I was as talented and
+# idiosyncratic as Larry Wall, I'd go ahead and implement a class that
+# somehow knows that "1.2.1" < "1.2.2a2" < "1.2.2" < "1.2.2pl3", and is
+# just as happy dealing with things like "2g6" and "1.13++". I don't
+# think I'm smart enough to do it right though.
+#
+# In any case, I've coded the test suite for this module (see
+# ../test/test_version.py) specifically to fail on things like comparing
+# "1.2a2" and "1.2". That's not because the *code* is doing anything
+# wrong, it's because the simple, obvious design doesn't match my
+# complicated, hairy expectations for real-world version numbers. It
+# would be a snap to fix the test suite to say, "Yep, LooseVersion does
+# the Right Thing" (ie. the code matches the conception). But I'd rather
+# have a conception that matches common notions about version numbers.
+
+class LooseVersion (Version):
+
+ """Version numbering for anarchists and software realists.
+ Implements the standard interface for version number classes as
+ described above. A version number consists of a series of numbers,
+ separated by either periods or strings of letters. When comparing
+ version numbers, the numeric components will be compared
+ numerically, and the alphabetic components lexically. The following
+ are all valid version numbers, in no particular order:
+
+ 1.5.1
+ 1.5.2b2
+ 161
+ 3.10a
+ 8.02
+ 3.4j
+ 1996.07.12
+ 3.2.pl0
+ 3.1.1.6
+ 2g6
+ 11g
+ 0.960923
+ 2.2beta29
+ 1.13++
+ 5.5.kw
+ 2.0b1pl0
+
+ In fact, there is no such thing as an invalid version number under
+ this scheme; the rules for comparison are simple and predictable,
+ but may not always give the results you want (for some definition
+ of "want").
+ """
+
+ component_re = re.compile(r'(\d+ | [a-z]+ | \.)', re.VERBOSE)
+
+ def __init__ (self, vstring=None):
+ if vstring:
+ self.parse(vstring)
+
+
+ def parse (self, vstring):
+ # I've given up on thinking I can reconstruct the version string
+ # from the parsed tuple -- so I just store the string here for
+ # use by __str__
+ self.vstring = vstring
+ components = [x for x in self.component_re.split(vstring)
+ if x and x != '.']
+ for i, obj in enumerate(components):
+ try:
+ components[i] = int(obj)
+ except ValueError:
+ pass
+
+ self.version = components
+
+
+ def __str__ (self):
+ return self.vstring
+
+
+ def __repr__ (self):
+ return "LooseVersion ('%s')" % str(self)
+
+
+ def _cmp (self, other):
+ if isinstance(other, str):
+ other = LooseVersion(other)
+ elif not isinstance(other, LooseVersion):
+ return NotImplemented
+
+ if self.version == other.version:
+ return 0
+ if self.version < other.version:
+ return -1
+ if self.version > other.version:
+ return 1
+
+
+# end class LooseVersion
diff --git a/electrum/_vendor/pyperclip/LICENSE.txt b/electrum/_vendor/pyperclip/LICENSE.txt
new file mode 100644
index 000000000000..799b74c5bf39
--- /dev/null
+++ b/electrum/_vendor/pyperclip/LICENSE.txt
@@ -0,0 +1,27 @@
+Copyright (c) 2014, Al Sweigart
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+* Neither the name of the {organization} nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/electrum/_vendor/pyperclip/README.md b/electrum/_vendor/pyperclip/README.md
new file mode 100644
index 000000000000..e4f43ef6a72f
--- /dev/null
+++ b/electrum/_vendor/pyperclip/README.md
@@ -0,0 +1,11 @@
+
+This is a stripped-down copy of the 3rd-party `pyperclip` package.
+
+It is used by the "text" GUI.
+
+At revision https://github.com/asweigart/pyperclip/blob/781603ea491eefce3b58f4f203bf748dbf9ff003/src/pyperclip/__init__.py
+(version 1.8.2)
+
+Modifications:
+- excluded most files
+- added support for pyqt6
diff --git a/electrum/_vendor/pyperclip/__init__.py b/electrum/_vendor/pyperclip/__init__.py
new file mode 100644
index 000000000000..8da648bd7077
--- /dev/null
+++ b/electrum/_vendor/pyperclip/__init__.py
@@ -0,0 +1,747 @@
+"""
+Pyperclip
+
+A cross-platform clipboard module for Python, with copy & paste functions for plain text.
+By Al Sweigart al@inventwithpython.com
+BSD License
+
+Usage:
+ import pyperclip
+ pyperclip.copy('The text to be copied to the clipboard.')
+ spam = pyperclip.paste()
+
+ if not pyperclip.is_available():
+ print("Copy functionality unavailable!")
+
+On Windows, no additional modules are needed.
+On Mac, the pyobjc module is used, falling back to the pbcopy and pbpaste cli
+ commands. (These commands should come with OS X.).
+On Linux, install xclip, xsel, or wl-clipboard (for "wayland" sessions) via package manager.
+For example, in Debian:
+ sudo apt-get install xclip
+ sudo apt-get install xsel
+ sudo apt-get install wl-clipboard
+
+Otherwise on Linux, you will need the gtk or PyQt5/PyQt4 modules installed.
+
+gtk and PyQt4 modules are not available for Python 3,
+and this module does not work with PyGObject yet.
+
+Note: There seems to be a way to get gtk on Python 3, according to:
+ https://askubuntu.com/questions/697397/python3-is-not-supporting-gtk-module
+
+Cygwin is currently not supported.
+
+Security Note: This module runs programs with these names:
+ - which
+ - where
+ - pbcopy
+ - pbpaste
+ - xclip
+ - xsel
+ - wl-copy/wl-paste
+ - klipper
+ - qdbus
+A malicious user could rename or add programs with these names, tricking
+Pyperclip into running them with whatever permissions the Python process has.
+
+"""
+__version__ = '1.8.2'
+
+import contextlib
+import ctypes
+import os
+import platform
+import subprocess
+import sys
+import time
+import warnings
+
+from ctypes import c_size_t, sizeof, c_wchar_p, get_errno, c_wchar
+
+
+# `import PyQt4` sys.exit()s if DISPLAY is not in the environment.
+# Thus, we need to detect the presence of $DISPLAY manually
+# and not load PyQt4 if it is absent.
+HAS_DISPLAY = os.getenv("DISPLAY", False)
+
+EXCEPT_MSG = """
+ Pyperclip could not find a copy/paste mechanism for your system.
+ For more information, please visit https://pyperclip.readthedocs.io/en/latest/index.html#not-implemented-error """
+
+PY2 = sys.version_info[0] == 2
+
+STR_OR_UNICODE = unicode if PY2 else str # For paste(): Python 3 uses str, Python 2 uses unicode.
+
+ENCODING = 'utf-8'
+
+try:
+ from shutil import which as _executable_exists
+except ImportError:
+ # The "which" unix command finds where a command is.
+ if platform.system() == 'Windows':
+ WHICH_CMD = 'where'
+ else:
+ WHICH_CMD = 'which'
+
+ def _executable_exists(name):
+ return subprocess.call([WHICH_CMD, name],
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE) == 0
+
+
+
+# Exceptions
+class PyperclipException(RuntimeError):
+ pass
+
+class PyperclipWindowsException(PyperclipException):
+ def __init__(self, message):
+ message += " (%s)" % ctypes.WinError()
+ super(PyperclipWindowsException, self).__init__(message)
+
+class PyperclipTimeoutException(PyperclipException):
+ pass
+
+def _stringifyText(text):
+ if PY2:
+ acceptedTypes = (unicode, str, int, float, bool)
+ else:
+ acceptedTypes = (str, int, float, bool)
+ if not isinstance(text, acceptedTypes):
+ raise PyperclipException('only str, int, float, and bool values can be copied to the clipboard, not %s' % (text.__class__.__name__))
+ return STR_OR_UNICODE(text)
+
+
+def init_osx_pbcopy_clipboard():
+
+ def copy_osx_pbcopy(text):
+ text = _stringifyText(text) # Converts non-str values to str.
+ p = subprocess.Popen(['pbcopy', 'w'],
+ stdin=subprocess.PIPE, close_fds=True)
+ p.communicate(input=text.encode(ENCODING))
+
+ def paste_osx_pbcopy():
+ p = subprocess.Popen(['pbpaste', 'r'],
+ stdout=subprocess.PIPE, close_fds=True)
+ stdout, stderr = p.communicate()
+ return stdout.decode(ENCODING)
+
+ return copy_osx_pbcopy, paste_osx_pbcopy
+
+
+def init_osx_pyobjc_clipboard():
+ def copy_osx_pyobjc(text):
+ '''Copy string argument to clipboard'''
+ text = _stringifyText(text) # Converts non-str values to str.
+ newStr = Foundation.NSString.stringWithString_(text).nsstring()
+ newData = newStr.dataUsingEncoding_(Foundation.NSUTF8StringEncoding)
+ board = AppKit.NSPasteboard.generalPasteboard()
+ board.declareTypes_owner_([AppKit.NSStringPboardType], None)
+ board.setData_forType_(newData, AppKit.NSStringPboardType)
+
+ def paste_osx_pyobjc():
+ "Returns contents of clipboard"
+ board = AppKit.NSPasteboard.generalPasteboard()
+ content = board.stringForType_(AppKit.NSStringPboardType)
+ return content
+
+ return copy_osx_pyobjc, paste_osx_pyobjc
+
+
+def init_gtk_clipboard():
+ global gtk
+ import gtk
+
+ def copy_gtk(text):
+ global cb
+ text = _stringifyText(text) # Converts non-str values to str.
+ cb = gtk.Clipboard()
+ cb.set_text(text)
+ cb.store()
+
+ def paste_gtk():
+ clipboardContents = gtk.Clipboard().wait_for_text()
+ # for python 2, returns None if the clipboard is blank.
+ if clipboardContents is None:
+ return ''
+ else:
+ return clipboardContents
+
+ return copy_gtk, paste_gtk
+
+
+def init_qt_clipboard():
+ global QApplication
+ # $DISPLAY should exist
+
+ # Try to import from qtpy, but if that fails try PyQt5 then PyQt4
+ try:
+ from qtpy.QtWidgets import QApplication
+ except:
+ try:
+ from PyQt6.QtWidgets import QApplication
+ except:
+ try:
+ from PyQt5.QtWidgets import QApplication
+ except:
+ from PyQt4.QtGui import QApplication
+
+ app = QApplication.instance()
+ if app is None:
+ app = QApplication([])
+
+ def copy_qt(text):
+ text = _stringifyText(text) # Converts non-str values to str.
+ cb = app.clipboard()
+ cb.setText(text)
+
+ def paste_qt():
+ cb = app.clipboard()
+ return STR_OR_UNICODE(cb.text())
+
+ return copy_qt, paste_qt
+
+
+def init_xclip_clipboard():
+ DEFAULT_SELECTION='c'
+ PRIMARY_SELECTION='p'
+
+ def copy_xclip(text, primary=False):
+ text = _stringifyText(text) # Converts non-str values to str.
+ selection=DEFAULT_SELECTION
+ if primary:
+ selection=PRIMARY_SELECTION
+ p = subprocess.Popen(['xclip', '-selection', selection],
+ stdin=subprocess.PIPE, close_fds=True)
+ p.communicate(input=text.encode(ENCODING))
+
+ def paste_xclip(primary=False):
+ selection=DEFAULT_SELECTION
+ if primary:
+ selection=PRIMARY_SELECTION
+ p = subprocess.Popen(['xclip', '-selection', selection, '-o'],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ close_fds=True)
+ stdout, stderr = p.communicate()
+ # Intentionally ignore extraneous output on stderr when clipboard is empty
+ return stdout.decode(ENCODING)
+
+ return copy_xclip, paste_xclip
+
+
+def init_xsel_clipboard():
+ DEFAULT_SELECTION='-b'
+ PRIMARY_SELECTION='-p'
+
+ def copy_xsel(text, primary=False):
+ text = _stringifyText(text) # Converts non-str values to str.
+ selection_flag = DEFAULT_SELECTION
+ if primary:
+ selection_flag = PRIMARY_SELECTION
+ p = subprocess.Popen(['xsel', selection_flag, '-i'],
+ stdin=subprocess.PIPE, close_fds=True)
+ p.communicate(input=text.encode(ENCODING))
+
+ def paste_xsel(primary=False):
+ selection_flag = DEFAULT_SELECTION
+ if primary:
+ selection_flag = PRIMARY_SELECTION
+ p = subprocess.Popen(['xsel', selection_flag, '-o'],
+ stdout=subprocess.PIPE, close_fds=True)
+ stdout, stderr = p.communicate()
+ return stdout.decode(ENCODING)
+
+ return copy_xsel, paste_xsel
+
+
+def init_wl_clipboard():
+ PRIMARY_SELECTION = "-p"
+
+ def copy_wl(text, primary=False):
+ text = _stringifyText(text) # Converts non-str values to str.
+ args = ["wl-copy"]
+ if primary:
+ args.append(PRIMARY_SELECTION)
+ if not text:
+ args.append('--clear')
+ subprocess.check_call(args, close_fds=True)
+ else:
+ pass
+ p = subprocess.Popen(args, stdin=subprocess.PIPE, close_fds=True)
+ p.communicate(input=text.encode(ENCODING))
+
+ def paste_wl(primary=False):
+ args = ["wl-paste", "-n"]
+ if primary:
+ args.append(PRIMARY_SELECTION)
+ p = subprocess.Popen(args, stdout=subprocess.PIPE, close_fds=True)
+ stdout, _stderr = p.communicate()
+ return stdout.decode(ENCODING)
+
+ return copy_wl, paste_wl
+
+
+def init_klipper_clipboard():
+ def copy_klipper(text):
+ text = _stringifyText(text) # Converts non-str values to str.
+ p = subprocess.Popen(
+ ['qdbus', 'org.kde.klipper', '/klipper', 'setClipboardContents',
+ text.encode(ENCODING)],
+ stdin=subprocess.PIPE, close_fds=True)
+ p.communicate(input=None)
+
+ def paste_klipper():
+ p = subprocess.Popen(
+ ['qdbus', 'org.kde.klipper', '/klipper', 'getClipboardContents'],
+ stdout=subprocess.PIPE, close_fds=True)
+ stdout, stderr = p.communicate()
+
+ # Workaround for https://bugs.kde.org/show_bug.cgi?id=342874
+ # TODO: https://github.com/asweigart/pyperclip/issues/43
+ clipboardContents = stdout.decode(ENCODING)
+ # even if blank, Klipper will append a newline at the end
+ assert len(clipboardContents) > 0
+ # make sure that newline is there
+ assert clipboardContents.endswith('\n')
+ if clipboardContents.endswith('\n'):
+ clipboardContents = clipboardContents[:-1]
+ return clipboardContents
+
+ return copy_klipper, paste_klipper
+
+
+def init_dev_clipboard_clipboard():
+ def copy_dev_clipboard(text):
+ text = _stringifyText(text) # Converts non-str values to str.
+ if text == '':
+ warnings.warn('Pyperclip cannot copy a blank string to the clipboard on Cygwin. This is effectively a no-op.')
+ if '\r' in text:
+ warnings.warn('Pyperclip cannot handle \\r characters on Cygwin.')
+
+ fo = open('/dev/clipboard', 'wt')
+ fo.write(text)
+ fo.close()
+
+ def paste_dev_clipboard():
+ fo = open('/dev/clipboard', 'rt')
+ content = fo.read()
+ fo.close()
+ return content
+
+ return copy_dev_clipboard, paste_dev_clipboard
+
+
+def init_no_clipboard():
+ class ClipboardUnavailable(object):
+
+ def __call__(self, *args, **kwargs):
+ raise PyperclipException(EXCEPT_MSG)
+
+ if PY2:
+ def __nonzero__(self):
+ return False
+ else:
+ def __bool__(self):
+ return False
+
+ return ClipboardUnavailable(), ClipboardUnavailable()
+
+
+
+
+# Windows-related clipboard functions:
+class CheckedCall(object):
+ def __init__(self, f):
+ super(CheckedCall, self).__setattr__("f", f)
+
+ def __call__(self, *args):
+ ret = self.f(*args)
+ if not ret and get_errno():
+ raise PyperclipWindowsException("Error calling " + self.f.__name__)
+ return ret
+
+ def __setattr__(self, key, value):
+ setattr(self.f, key, value)
+
+
+def init_windows_clipboard():
+ global HGLOBAL, LPVOID, DWORD, LPCSTR, INT, HWND, HINSTANCE, HMENU, BOOL, UINT, HANDLE
+ from ctypes.wintypes import (HGLOBAL, LPVOID, DWORD, LPCSTR, INT, HWND,
+ HINSTANCE, HMENU, BOOL, UINT, HANDLE)
+
+ windll = ctypes.windll
+ msvcrt = ctypes.CDLL('msvcrt')
+
+ safeCreateWindowExA = CheckedCall(windll.user32.CreateWindowExA)
+ safeCreateWindowExA.argtypes = [DWORD, LPCSTR, LPCSTR, DWORD, INT, INT,
+ INT, INT, HWND, HMENU, HINSTANCE, LPVOID]
+ safeCreateWindowExA.restype = HWND
+
+ safeDestroyWindow = CheckedCall(windll.user32.DestroyWindow)
+ safeDestroyWindow.argtypes = [HWND]
+ safeDestroyWindow.restype = BOOL
+
+ OpenClipboard = windll.user32.OpenClipboard
+ OpenClipboard.argtypes = [HWND]
+ OpenClipboard.restype = BOOL
+
+ safeCloseClipboard = CheckedCall(windll.user32.CloseClipboard)
+ safeCloseClipboard.argtypes = []
+ safeCloseClipboard.restype = BOOL
+
+ safeEmptyClipboard = CheckedCall(windll.user32.EmptyClipboard)
+ safeEmptyClipboard.argtypes = []
+ safeEmptyClipboard.restype = BOOL
+
+ safeGetClipboardData = CheckedCall(windll.user32.GetClipboardData)
+ safeGetClipboardData.argtypes = [UINT]
+ safeGetClipboardData.restype = HANDLE
+
+ safeSetClipboardData = CheckedCall(windll.user32.SetClipboardData)
+ safeSetClipboardData.argtypes = [UINT, HANDLE]
+ safeSetClipboardData.restype = HANDLE
+
+ safeGlobalAlloc = CheckedCall(windll.kernel32.GlobalAlloc)
+ safeGlobalAlloc.argtypes = [UINT, c_size_t]
+ safeGlobalAlloc.restype = HGLOBAL
+
+ safeGlobalLock = CheckedCall(windll.kernel32.GlobalLock)
+ safeGlobalLock.argtypes = [HGLOBAL]
+ safeGlobalLock.restype = LPVOID
+
+ safeGlobalUnlock = CheckedCall(windll.kernel32.GlobalUnlock)
+ safeGlobalUnlock.argtypes = [HGLOBAL]
+ safeGlobalUnlock.restype = BOOL
+
+ wcslen = CheckedCall(msvcrt.wcslen)
+ wcslen.argtypes = [c_wchar_p]
+ wcslen.restype = UINT
+
+ GMEM_MOVEABLE = 0x0002
+ CF_UNICODETEXT = 13
+
+ @contextlib.contextmanager
+ def window():
+ """
+ Context that provides a valid Windows hwnd.
+ """
+ # we really just need the hwnd, so setting "STATIC"
+ # as predefined lpClass is just fine.
+ hwnd = safeCreateWindowExA(0, b"STATIC", None, 0, 0, 0, 0, 0,
+ None, None, None, None)
+ try:
+ yield hwnd
+ finally:
+ safeDestroyWindow(hwnd)
+
+ @contextlib.contextmanager
+ def clipboard(hwnd):
+ """
+ Context manager that opens the clipboard and prevents
+ other applications from modifying the clipboard content.
+ """
+ # We may not get the clipboard handle immediately because
+ # some other application is accessing it (?)
+ # We try for at least 500ms to get the clipboard.
+ t = time.time() + 0.5
+ success = False
+ while time.time() < t:
+ success = OpenClipboard(hwnd)
+ if success:
+ break
+ time.sleep(0.01)
+ if not success:
+ raise PyperclipWindowsException("Error calling OpenClipboard")
+
+ try:
+ yield
+ finally:
+ safeCloseClipboard()
+
+ def copy_windows(text):
+ # This function is heavily based on
+ # http://msdn.com/ms649016#_win32_Copying_Information_to_the_Clipboard
+
+ text = _stringifyText(text) # Converts non-str values to str.
+
+ with window() as hwnd:
+ # http://msdn.com/ms649048
+ # If an application calls OpenClipboard with hwnd set to NULL,
+ # EmptyClipboard sets the clipboard owner to NULL;
+ # this causes SetClipboardData to fail.
+ # => We need a valid hwnd to copy something.
+ with clipboard(hwnd):
+ safeEmptyClipboard()
+
+ if text:
+ # http://msdn.com/ms649051
+ # If the hMem parameter identifies a memory object,
+ # the object must have been allocated using the
+ # function with the GMEM_MOVEABLE flag.
+ count = wcslen(text) + 1
+ handle = safeGlobalAlloc(GMEM_MOVEABLE,
+ count * sizeof(c_wchar))
+ locked_handle = safeGlobalLock(handle)
+
+ ctypes.memmove(c_wchar_p(locked_handle), c_wchar_p(text), count * sizeof(c_wchar))
+
+ safeGlobalUnlock(handle)
+ safeSetClipboardData(CF_UNICODETEXT, handle)
+
+ def paste_windows():
+ with clipboard(None):
+ handle = safeGetClipboardData(CF_UNICODETEXT)
+ if not handle:
+ # GetClipboardData may return NULL with errno == NO_ERROR
+ # if the clipboard is empty.
+ # (Also, it may return a handle to an empty buffer,
+ # but technically that's not empty)
+ return ""
+ locked_handle = safeGlobalLock(handle)
+ return_value = c_wchar_p(locked_handle).value
+ safeGlobalUnlock(handle)
+ return return_value
+
+ return copy_windows, paste_windows
+
+
+def init_wsl_clipboard():
+ def copy_wsl(text):
+ text = _stringifyText(text) # Converts non-str values to str.
+ p = subprocess.Popen(['clip.exe'],
+ stdin=subprocess.PIPE, close_fds=True)
+ p.communicate(input=text.encode(ENCODING))
+
+ def paste_wsl():
+ # '-noprofile' speeds up load time
+ p = subprocess.Popen(['powershell.exe', '-noprofile', '-command', 'Get-Clipboard'],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ close_fds=True)
+ stdout, stderr = p.communicate()
+ # WSL appends "\r\n" to the contents.
+ return stdout[:-2].decode(ENCODING)
+
+ return copy_wsl, paste_wsl
+
+
+# Automatic detection of clipboard mechanisms and importing is done in deteremine_clipboard():
+def determine_clipboard():
+ '''
+ Determine the OS/platform and set the copy() and paste() functions
+ accordingly.
+ '''
+
+ global Foundation, AppKit, gtk, qtpy, PyQt4, PyQt5, PyQt6
+
+ # Setup for the CYGWIN platform:
+ if 'cygwin' in platform.system().lower(): # Cygwin has a variety of values returned by platform.system(), such as 'CYGWIN_NT-6.1'
+ # FIXME: pyperclip currently does not support Cygwin,
+ # see https://github.com/asweigart/pyperclip/issues/55
+ if os.path.exists('/dev/clipboard'):
+ warnings.warn('Pyperclip\'s support for Cygwin is not perfect, see https://github.com/asweigart/pyperclip/issues/55')
+ return init_dev_clipboard_clipboard()
+
+ # Setup for the WINDOWS platform:
+ elif os.name == 'nt' or platform.system() == 'Windows':
+ return init_windows_clipboard()
+
+ if platform.system() == 'Linux' and os.path.isfile('/proc/version'):
+ with open('/proc/version', 'r') as f:
+ if "microsoft" in f.read().lower():
+ return init_wsl_clipboard()
+
+ # Setup for the MAC OS X platform:
+ if os.name == 'mac' or platform.system() == 'Darwin':
+ try:
+ import Foundation # check if pyobjc is installed
+ import AppKit
+ except ImportError:
+ return init_osx_pbcopy_clipboard()
+ else:
+ return init_osx_pyobjc_clipboard()
+
+ # Setup for the LINUX platform:
+ if HAS_DISPLAY:
+ try:
+ import gtk # check if gtk is installed
+ except ImportError:
+ pass # We want to fail fast for all non-ImportError exceptions.
+ else:
+ return init_gtk_clipboard()
+
+ if (
+ os.environ.get("WAYLAND_DISPLAY") and
+ _executable_exists("wl-copy")
+ ):
+ return init_wl_clipboard()
+ if _executable_exists("xsel"):
+ return init_xsel_clipboard()
+ if _executable_exists("xclip"):
+ return init_xclip_clipboard()
+ if _executable_exists("klipper") and _executable_exists("qdbus"):
+ return init_klipper_clipboard()
+
+ try:
+ # qtpy is a small abstraction layer that lets you write applications using a single api call to either PyQt or PySide.
+ # https://pypi.python.org/pypi/QtPy
+ import qtpy # check if qtpy is installed
+ except ImportError:
+ # If qtpy isn't installed, fall back on importing PyQt4.
+ try:
+ import PyQt6 # check if PyQt6 is installed
+ except ImportError:
+ try:
+ import PyQt5 # check if PyQt5 is installed
+ except ImportError:
+ try:
+ import PyQt4 # check if PyQt4 is installed
+ except ImportError:
+ pass # We want to fail fast for all non-ImportError exceptions.
+ else:
+ return init_qt_clipboard()
+ else:
+ return init_qt_clipboard()
+ else:
+ return init_qt_clipboard()
+ else:
+ return init_qt_clipboard()
+
+
+ return init_no_clipboard()
+
+
+def set_clipboard(clipboard):
+ '''
+ Explicitly sets the clipboard mechanism. The "clipboard mechanism" is how
+ the copy() and paste() functions interact with the operating system to
+ implement the copy/paste feature. The clipboard parameter must be one of:
+ - pbcopy
+ - pbobjc (default on Mac OS X)
+ - gtk
+ - qt
+ - xclip
+ - xsel
+ - klipper
+ - windows (default on Windows)
+ - no (this is what is set when no clipboard mechanism can be found)
+ '''
+ global copy, paste
+
+ clipboard_types = {
+ "pbcopy": init_osx_pbcopy_clipboard,
+ "pyobjc": init_osx_pyobjc_clipboard,
+ "gtk": init_gtk_clipboard,
+ "qt": init_qt_clipboard, # TODO - split this into 'qtpy', 'pyqt4', and 'pyqt5'
+ "xclip": init_xclip_clipboard,
+ "xsel": init_xsel_clipboard,
+ "wl-clipboard": init_wl_clipboard,
+ "klipper": init_klipper_clipboard,
+ "windows": init_windows_clipboard,
+ "no": init_no_clipboard,
+ }
+
+ if clipboard not in clipboard_types:
+ raise ValueError('Argument must be one of %s' % (', '.join([repr(_) for _ in clipboard_types.keys()])))
+
+ # Sets pyperclip's copy() and paste() functions:
+ copy, paste = clipboard_types[clipboard]()
+
+
+def lazy_load_stub_copy(text):
+ '''
+ A stub function for copy(), which will load the real copy() function when
+ called so that the real copy() function is used for later calls.
+
+ This allows users to import pyperclip without having determine_clipboard()
+ automatically run, which will automatically select a clipboard mechanism.
+ This could be a problem if it selects, say, the memory-heavy PyQt4 module
+ but the user was just going to immediately call set_clipboard() to use a
+ different clipboard mechanism.
+
+ The lazy loading this stub function implements gives the user a chance to
+ call set_clipboard() to pick another clipboard mechanism. Or, if the user
+ simply calls copy() or paste() without calling set_clipboard() first,
+ will fall back on whatever clipboard mechanism that determine_clipboard()
+ automatically chooses.
+ '''
+ global copy, paste
+ copy, paste = determine_clipboard()
+ return copy(text)
+
+
+def lazy_load_stub_paste():
+ '''
+ A stub function for paste(), which will load the real paste() function when
+ called so that the real paste() function is used for later calls.
+
+ This allows users to import pyperclip without having determine_clipboard()
+ automatically run, which will automatically select a clipboard mechanism.
+ This could be a problem if it selects, say, the memory-heavy PyQt4 module
+ but the user was just going to immediately call set_clipboard() to use a
+ different clipboard mechanism.
+
+ The lazy loading this stub function implements gives the user a chance to
+ call set_clipboard() to pick another clipboard mechanism. Or, if the user
+ simply calls copy() or paste() without calling set_clipboard() first,
+ will fall back on whatever clipboard mechanism that determine_clipboard()
+ automatically chooses.
+ '''
+ global copy, paste
+ copy, paste = determine_clipboard()
+ return paste()
+
+
+def is_available():
+ return copy != lazy_load_stub_copy and paste != lazy_load_stub_paste
+
+
+# Initially, copy() and paste() are set to lazy loading wrappers which will
+# set `copy` and `paste` to real functions the first time they're used, unless
+# set_clipboard() or determine_clipboard() is called first.
+copy, paste = lazy_load_stub_copy, lazy_load_stub_paste
+
+
+
+def waitForPaste(timeout=None):
+ """This function call blocks until a non-empty text string exists on the
+ clipboard. It returns this text.
+
+ This function raises PyperclipTimeoutException if timeout was set to
+ a number of seconds that has elapsed without non-empty text being put on
+ the clipboard."""
+ startTime = time.time()
+ while True:
+ clipboardText = paste()
+ if clipboardText != '':
+ return clipboardText
+ time.sleep(0.01)
+
+ if timeout is not None and time.time() > startTime + timeout:
+ raise PyperclipTimeoutException('waitForPaste() timed out after ' + str(timeout) + ' seconds.')
+
+
+def waitForNewPaste(timeout=None):
+ """This function call blocks until a new text string exists on the
+ clipboard that is different from the text that was there when the function
+ was first called. It returns this text.
+
+ This function raises PyperclipTimeoutException if timeout was set to
+ a number of seconds that has elapsed without non-empty text being put on
+ the clipboard."""
+ startTime = time.time()
+ originalText = paste()
+ while True:
+ currentText = paste()
+ if currentText != originalText:
+ return currentText
+ time.sleep(0.01)
+
+ if timeout is not None and time.time() > startTime + timeout:
+ raise PyperclipTimeoutException('waitForNewPaste() timed out after ' + str(timeout) + ' seconds.')
+
+
+__all__ = ['copy', 'paste', 'waitForPaste', 'waitForNewPaste', 'set_clipboard', 'determine_clipboard']
+
+
diff --git a/electrum/address_synchronizer.py b/electrum/address_synchronizer.py
new file mode 100644
index 000000000000..be57de1eb791
--- /dev/null
+++ b/electrum/address_synchronizer.py
@@ -0,0 +1,1087 @@
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2018 The Electrum Developers
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import asyncio
+import copy
+import dataclasses
+import threading
+import itertools
+from collections import defaultdict
+from typing import TYPE_CHECKING, Dict, Optional, Set, Tuple, NamedTuple, Sequence, List
+
+from .crypto import sha256
+from . import bitcoin, util
+from .bitcoin import COINBASE_MATURITY
+from .util import profiler, bfh, TxMinedInfo, UnrelatedTransactionException, with_lock, OldTaskGroup
+from .transaction import Transaction, TxOutput, TxInput, PartialTxInput, TxOutpoint, PartialTransaction, tx_from_any
+from .synchronizer import Synchronizer
+from .verifier import SPV
+from .blockchain import hash_header, Blockchain
+from .i18n import _
+from .logging import Logger
+from .util import EventListener, event_listener
+
+if TYPE_CHECKING:
+ from .network import Network
+ from .wallet_db import WalletDB
+ from .simple_config import SimpleConfig
+
+
+TX_HEIGHT_FUTURE = -3
+TX_HEIGHT_LOCAL = -2
+TX_HEIGHT_UNCONF_PARENT = -1
+TX_HEIGHT_UNCONFIRMED = 0
+
+TX_TIMESTAMP_INF = 999_999_999_999
+TX_HEIGHT_INF = 10 ** 9
+
+
+from enum import IntEnum, auto
+
+class TxMinedDepth(IntEnum):
+ """ IntEnum because we call min() in get_deepest_tx_mined_depth_for_txids """
+ DEEP = auto()
+ SHALLOW = auto()
+ MEMPOOL = auto()
+ FREE = auto()
+
+
+class HistoryItem(NamedTuple):
+ txid: str
+ tx_mined_status: TxMinedInfo
+ delta: int
+ fee: Optional[int]
+ balance: int
+
+
+class AddressSynchronizer(Logger, EventListener):
+ """ address database """
+
+ network: Optional['Network']
+ asyncio_loop: Optional['asyncio.AbstractEventLoop'] = None
+ synchronizer: Optional['Synchronizer']
+ verifier: Optional['SPV']
+
+ def __init__(self, db: 'WalletDB', config: 'SimpleConfig', *, name: str = None):
+ self.db = db
+ self.config = config
+ self.name = name
+ self.network = None
+ Logger.__init__(self)
+ # verifier (SPV) and synchronizer are started in start_network
+ self.synchronizer = None
+ self.verifier = None
+ self.lock = threading.RLock()
+ self.future_tx = {} # type: Dict[str, int] # txid -> wanted (abs) height
+ # Txs the server claims are mined but still pending verification:
+ self.unverified_tx = defaultdict(int) # type: Dict[str, int] # txid -> height. Access with self.lock.
+ # Txs the server claims are in the mempool:
+ self.unconfirmed_tx = defaultdict(int) # type: Dict[str, int] # txid -> height. Access with self.lock.
+ # thread local storage for caching stuff
+ self.threadlocal_cache = threading.local()
+
+ self._get_balance_cache = {}
+ self._get_utxos_cache = {}
+
+ self.load_and_cleanup()
+
+ @with_lock
+ def invalidate_cache(self):
+ self._get_balance_cache.clear()
+ self._get_utxos_cache.clear()
+
+ def diagnostic_name(self):
+ return self.name or ""
+
+ @with_lock
+ def load_and_cleanup(self):
+ self.load_local_history()
+ self.check_history()
+ self.load_unverified_transactions()
+ self.remove_local_transactions_we_dont_have()
+
+ def is_mine(self, address: Optional[str]) -> bool:
+ """Returns whether an address is in our set.
+
+ Differences between adb.is_mine and wallet.is_mine:
+ - adb.is_mine: addrs that we are watching (e.g. via Synchronizer)
+ - lnwatcher adds its own lightning-related addresses that are not part of the wallet
+ - wallet.is_mine: addrs that are part of the wallet balance or the wallet might sign for
+ - an offline wallet might learn from a PSBT about addrs beyond its gap limit
+ Neither set is guaranteed to be a subset of the other.
+ """
+ if not address: return False
+ return self.db.is_addr_in_history(address)
+
+ def get_addresses(self):
+ return sorted(self.db.get_history())
+
+ @with_lock
+ def get_address_history(self, addr: str) -> Dict[str, int]:
+ """Returns the history for the address, as a txid->height dict.
+ In addition to what we have from the server, this includes local and future txns.
+ Note: heights are SPV-verified.
+
+ Also see related method db.get_addr_history, which stores the response from the server,
+ so that only includes txns the server sees.
+ """
+ h = {}
+ related_txns = self._history_local.get(addr, set())
+ for tx_hash in related_txns:
+ tx_height = self.get_tx_height(tx_hash).height()
+ h[tx_hash] = tx_height
+ return h
+
+ def get_address_history_len(self, addr: str) -> int:
+ """Return number of transactions where address is involved."""
+ return len(self._history_local.get(addr, ()))
+
+ @with_lock
+ def get_txin_address(self, txin: TxInput) -> Optional[str]:
+ if txin.address:
+ return txin.address
+ prevout_hash = txin.prevout.txid.hex()
+ prevout_n = txin.prevout.out_idx
+ for addr in self.db.get_txo_addresses(prevout_hash):
+ d = self.db.get_txo_addr(prevout_hash, addr)
+ if prevout_n in d:
+ return addr
+ tx = self.db.get_transaction(prevout_hash)
+ if tx:
+ return tx.outputs()[prevout_n].address
+ return None
+
+ @with_lock
+ def get_txin_value(self, txin: TxInput, *, address: str = None) -> Optional[int]:
+ if txin.value_sats() is not None:
+ return txin.value_sats()
+ prevout_hash = txin.prevout.txid.hex()
+ prevout_n = txin.prevout.out_idx
+ if address is None:
+ address = self.get_txin_address(txin)
+ if address:
+ d = self.db.get_txo_addr(prevout_hash, address)
+ try:
+ v, cb = d[prevout_n]
+ return v
+ except KeyError:
+ pass
+ tx = self.db.get_transaction(prevout_hash)
+ if tx:
+ return tx.outputs()[prevout_n].value
+ return None
+
+ @with_lock
+ def load_unverified_transactions(self):
+ # review transactions that are in the history
+ for addr in self.db.get_history():
+ hist = self.db.get_addr_history(addr)
+ for tx_hash, tx_height in hist:
+ # add it in case it was previously unconfirmed
+ self.add_unverified_or_unconfirmed_tx(tx_hash, tx_height)
+
+ def start_network(self, network: Optional['Network']) -> None:
+ assert self.network is None, "already started"
+ self.network = network
+ if self.network is not None:
+ self.synchronizer = Synchronizer(self)
+ self.verifier = SPV(self.network, self)
+ self.asyncio_loop = network.asyncio_loop
+ self.register_callbacks()
+ self._update_stored_local_height()
+
+ @event_listener
+ @with_lock
+ def on_event_blockchain_updated(self, *args):
+ self.invalidate_cache()
+ self._update_stored_local_height()
+
+ async def stop(self):
+ if self.network:
+ try:
+ async with OldTaskGroup() as group:
+ if self.synchronizer:
+ await group.spawn(self.synchronizer.stop())
+ if self.verifier:
+ await group.spawn(self.verifier.stop())
+ finally: # even if we get cancelled
+ self.synchronizer = None
+ self.verifier = None
+ self.unregister_callbacks()
+ self.network = None
+
+ def add_address(self, address: str) -> None:
+ if address not in self.db.history:
+ self.db.history[address] = []
+ if self.synchronizer:
+ self.synchronizer.add(address)
+ self.up_to_date_changed()
+
+ @with_lock
+ def get_conflicting_transactions(self, tx: Transaction, *, include_self: bool = False) -> Set[str]:
+ """Returns a set of transaction hashes from the wallet history that are
+ directly conflicting with tx, i.e. they have common outpoints being
+ spent with tx.
+
+ include_self specifies whether the tx itself should be reported as a
+ conflict (if already in wallet history)
+ """
+ conflicting_txns = set()
+ for txin in tx.inputs():
+ if txin.is_coinbase_input():
+ continue
+ prevout_hash = txin.prevout.txid.hex()
+ prevout_n = txin.prevout.out_idx
+ spending_tx_hash = self.db.get_spent_outpoint(prevout_hash, prevout_n)
+ if spending_tx_hash is None:
+ continue
+ # this outpoint has already been spent, by spending_tx
+ # annoying assert that has revealed several bugs over time:
+ assert self.db.get_transaction(spending_tx_hash), "spending tx not in wallet db"
+ conflicting_txns |= {spending_tx_hash}
+ if tx_hash := tx.txid():
+ if tx_hash in conflicting_txns:
+ # this tx is already in history, so it conflicts with itself
+ if len(conflicting_txns) > 1:
+ raise Exception('Found conflicting transactions already in wallet history.')
+ if not include_self:
+ conflicting_txns -= {tx_hash}
+ return conflicting_txns
+
+ @with_lock
+ def get_transaction(self, txid: str) -> Optional[Transaction]:
+ tx = self.db.get_transaction(txid)
+ if tx:
+ tx.deserialize()
+ for txin in tx._inputs:
+ tx_mined_info = self.get_tx_height(txin.prevout.txid.hex())
+ txin.block_height = tx_mined_info.height()
+ txin.block_txpos = tx_mined_info.txpos
+ return tx
+
+ def add_transaction(self, tx: Transaction, *, allow_unrelated=False, is_new=True) -> bool:
+ """
+ Returns whether the tx was successfully added to the wallet history.
+ Note that a transaction may need to be added several times, if our
+ list of addresses has increased. This will return True even if the
+ transaction was already in self.db.
+ """
+ assert tx, tx
+ # note: tx.is_complete() is not necessarily True; tx might be partial
+ # but it *needs* to have a txid:
+ tx_hash = tx.txid()
+ if tx_hash is None:
+ raise Exception("cannot add tx without txid to wallet history")
+ # For sanity, try to serialize and deserialize tx early:
+ tx_from_any(str(tx), sanitize=False) # see if raises (no-side-effects)
+ with self.lock:
+ # NOTE: returning if tx in self.transactions might seem like a good idea
+ # BUT we track is_mine inputs in a txn, and during subsequent calls
+ # of add_transaction tx, we might learn of more-and-more inputs of
+ # being is_mine, as we roll the gap_limit forward
+ is_coinbase = tx.inputs()[0].is_coinbase_input()
+ tx_height = self.get_tx_height(tx_hash, force_local_if_missing_tx=False).height()
+ if not allow_unrelated:
+ # note that during sync, if the transactions are not properly sorted,
+ # it could happen that we think tx is unrelated but actually one of the inputs is is_mine.
+ # this is the main motivation for allow_unrelated
+ is_mine = any([self.is_mine(self.get_txin_address(txin)) for txin in tx.inputs()])
+ is_for_me = any([self.is_mine(txo.address) for txo in tx.outputs()])
+ if not is_mine and not is_for_me:
+ raise UnrelatedTransactionException()
+ # Find all conflicting transactions.
+ # In case of a conflict,
+ # 1. confirmed > mempool > local
+ # 2. this new txn has priority over existing ones
+ # When this method exits, there must NOT be any conflict, so
+ # either keep this txn and remove all conflicting (along with dependencies)
+ # or drop this txn
+ conflicting_txns = self.get_conflicting_transactions(tx)
+ if conflicting_txns:
+ existing_mempool_txn = any(
+ self.get_tx_height(tx_hash2).height() in (TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_UNCONF_PARENT)
+ for tx_hash2 in conflicting_txns)
+ existing_confirmed_txn = any(
+ self.get_tx_height(tx_hash2).height() > 0
+ for tx_hash2 in conflicting_txns)
+ if existing_confirmed_txn and tx_height <= 0:
+ # this is a non-confirmed tx that conflicts with confirmed txns; drop.
+ return False
+ if existing_mempool_txn and tx_height == TX_HEIGHT_LOCAL:
+ # this is a local tx that conflicts with non-local txns; drop.
+ return False
+ # keep this txn and remove all conflicting
+ for tx_hash2 in conflicting_txns:
+ self.remove_transaction(tx_hash2)
+ # add inputs
+ def add_value_from_prev_output():
+ # note: this takes linear time in num is_mine outputs of prev_tx
+ addr = self.get_txin_address(txi)
+ if addr and self.is_mine(addr):
+ outputs = self.db.get_txo_addr(prevout_hash, addr)
+ try:
+ v, is_cb = outputs[prevout_n]
+ except KeyError:
+ pass
+ else:
+ self.db.add_txi_addr(tx_hash, addr, ser, v)
+ self.invalidate_cache()
+ for txi in tx.inputs():
+ if txi.is_coinbase_input():
+ continue
+ prevout_hash = txi.prevout.txid.hex()
+ prevout_n = txi.prevout.out_idx
+ ser = txi.prevout.to_str()
+ self.db.set_spent_outpoint(prevout_hash, prevout_n, tx_hash)
+ add_value_from_prev_output()
+ # add outputs
+ for n, txo in enumerate(tx.outputs()):
+ v = txo.value
+ ser = tx_hash + ':%d'%n
+ scripthash = bitcoin.script_to_scripthash(txo.scriptpubkey)
+ self.db.add_prevout_by_scripthash(scripthash, prevout=TxOutpoint.from_str(ser), value=v)
+ addr = txo.address
+ if addr and self.is_mine(addr):
+ self.db.add_txo_addr(tx_hash, addr, n, v, is_coinbase)
+ self.invalidate_cache()
+ # give v to txi that spends me
+ next_tx = self.db.get_spent_outpoint(tx_hash, n)
+ if next_tx is not None:
+ self.db.add_txi_addr(next_tx, addr, ser, v)
+ self._add_tx_to_local_history(next_tx)
+ # add to local history
+ self._add_tx_to_local_history(tx_hash)
+ # save
+ self.db.add_transaction(tx_hash, tx)
+ self.db.add_num_inputs_to_tx(tx_hash, len(tx.inputs()))
+ if is_new:
+ util.trigger_callback('adb_added_tx', self, tx_hash, tx)
+ return True
+
+ @with_lock
+ def remove_transaction(self, tx_hash: str) -> None:
+ """Removes a transaction AND all its dependents/children
+ from the wallet history.
+ """
+ to_remove = {tx_hash}
+ to_remove |= self.get_depending_transactions(tx_hash)
+ for txid in to_remove:
+ self._remove_transaction(txid)
+
+ def _remove_transaction(self, tx_hash: str) -> None:
+ """Removes a single transaction from the wallet history, and attempts
+ to undo all effects of the tx (spending inputs, creating outputs, etc).
+ """
+ def remove_from_spent_outpoints():
+ # undo spends in spent_outpoints
+ if tx is not None:
+ # if we have the tx, this branch is faster
+ for txin in tx.inputs():
+ if txin.is_coinbase_input():
+ continue
+ prevout_hash = txin.prevout.txid.hex()
+ prevout_n = txin.prevout.out_idx
+ self.db.remove_spent_outpoint(prevout_hash, prevout_n)
+ else:
+ # expensive but always works
+ for prevout_hash, prevout_n in self.db.list_spent_outpoints():
+ spending_txid = self.db.get_spent_outpoint(prevout_hash, prevout_n)
+ if spending_txid == tx_hash:
+ self.db.remove_spent_outpoint(prevout_hash, prevout_n)
+
+ with self.lock:
+ self.logger.info(f"removing tx from history {tx_hash}")
+ tx = self.db.remove_transaction(tx_hash)
+ remove_from_spent_outpoints()
+ self._remove_tx_from_local_history(tx_hash)
+ self.invalidate_cache()
+ self.db.remove_txi(tx_hash)
+ self.db.remove_txo(tx_hash)
+ self.db.remove_tx_fee(tx_hash)
+ self.db.remove_verified_tx(tx_hash)
+ self.unverified_tx.pop(tx_hash, None)
+ self.unconfirmed_tx.pop(tx_hash, None)
+ if tx:
+ for idx, txo in enumerate(tx.outputs()):
+ scripthash = bitcoin.script_to_scripthash(txo.scriptpubkey)
+ prevout = TxOutpoint(bfh(tx_hash), idx)
+ self.db.remove_prevout_by_scripthash(scripthash, prevout=prevout, value=txo.value)
+ util.trigger_callback('adb_removed_tx', self, tx_hash, tx)
+
+ @with_lock
+ def get_depending_transactions(self, tx_hash: str) -> Set[str]:
+ """Returns all (grand-)children of tx_hash in this wallet."""
+ children = set()
+ for n in self.db.get_spent_outpoints(tx_hash):
+ other_hash = self.db.get_spent_outpoint(tx_hash, n)
+ children.add(other_hash)
+ children |= self.get_depending_transactions(other_hash)
+ return children
+
+ @with_lock
+ def receive_tx_callback(self, tx: Transaction, *, tx_height: Optional[int] = None) -> None:
+ txid = tx.txid()
+ assert txid is not None
+ if tx_height is not None:
+ # note: tx_height is only set by the unit tests: to inject a tx into the history
+ self.add_unverified_or_unconfirmed_tx(txid, tx_height)
+ self.add_transaction(tx, allow_unrelated=True)
+
+ @with_lock
+ def receive_history_callback(self, addr: str, hist, tx_fees: Dict[str, int]):
+ old_hist = self.get_address_history(addr)
+ for tx_hash, height in old_hist.items():
+ if (tx_hash, height) not in hist:
+ # make tx local
+ self.unverified_tx.pop(tx_hash, None)
+ self.unconfirmed_tx.pop(tx_hash, None)
+ self.db.remove_verified_tx(tx_hash)
+ if self.verifier:
+ self.verifier.remove_spv_proof_for_tx(tx_hash)
+ self.db.set_addr_history(addr, hist)
+
+ for tx_hash, tx_height in hist:
+ # add it in case it was previously unconfirmed
+ self.add_unverified_or_unconfirmed_tx(tx_hash, tx_height)
+ # if addr is new, we have to recompute txi and txo
+ tx = self.db.get_transaction(tx_hash)
+ if tx is None:
+ continue
+ self.add_transaction(tx, allow_unrelated=True, is_new=False)
+ # if we already had this tx, see if its height changed (e.g. local->unconfirmed)
+ old_height = old_hist.get(tx_hash, None)
+ if old_height is not None and old_height != tx_height:
+ util.trigger_callback('adb_tx_height_changed', self, tx_hash, old_height, tx_height)
+
+ # Store fees
+ for tx_hash, fee_sat in tx_fees.items():
+ self.db.add_tx_fee_from_server(tx_hash, fee_sat)
+
+ @with_lock
+ @profiler
+ def load_local_history(self):
+ self._history_local = {} # type: Dict[str, Set[str]] # address -> set(txid)
+ self._address_history_changed_events = defaultdict(asyncio.Event) # address -> Event
+ for txid in itertools.chain(self.db.list_txi(), self.db.list_txo()):
+ self._add_tx_to_local_history(txid)
+
+ @with_lock
+ @profiler
+ def check_history(self):
+ hist_addrs_mine = list(filter(lambda k: self.is_mine(k), self.db.get_history()))
+ hist_addrs_not_mine = list(filter(lambda k: not self.is_mine(k), self.db.get_history()))
+ for addr in hist_addrs_not_mine:
+ self.db.remove_addr_history(addr)
+ for addr in hist_addrs_mine:
+ hist = self.db.get_addr_history(addr)
+ for tx_hash, tx_height in hist:
+ if self.db.get_txi_addresses(tx_hash) or self.db.get_txo_addresses(tx_hash):
+ continue
+ tx = self.db.get_transaction(tx_hash)
+ if tx is not None:
+ self.add_transaction(tx, allow_unrelated=True)
+
+ @with_lock
+ def remove_local_transactions_we_dont_have(self):
+ for txid in itertools.chain(self.db.list_txi(), self.db.list_txo()):
+ tx_height = self.get_tx_height(txid).height()
+ if tx_height == TX_HEIGHT_LOCAL and not self.db.get_transaction(txid):
+ self.remove_transaction(txid)
+
+ @with_lock
+ def clear_history(self):
+ self.db.clear_history()
+ self._history_local.clear()
+ self.invalidate_cache()
+
+ @with_lock
+ def _get_tx_sort_key(self, tx_hash: str) -> Tuple[int, int]:
+ """Returns a key to be used for sorting txs."""
+ tx_mined_info = self.get_tx_height(tx_hash)
+ height = self.tx_height_to_sort_height(tx_mined_info.height())
+ txpos = tx_mined_info.txpos or -1
+ return height, txpos
+
+ @classmethod
+ def tx_height_to_sort_height(cls, height: int = None):
+ """Return a height-like value to be used for sorting txs."""
+ if height is not None:
+ if height > 0:
+ return height
+ if height == TX_HEIGHT_UNCONFIRMED:
+ return TX_HEIGHT_INF
+ if height == TX_HEIGHT_UNCONF_PARENT:
+ return TX_HEIGHT_INF + 1
+ if height == TX_HEIGHT_FUTURE:
+ return TX_HEIGHT_INF + 2
+ if height == TX_HEIGHT_LOCAL:
+ return TX_HEIGHT_INF + 3
+ return TX_HEIGHT_INF + 100
+
+ def with_local_height_cached(func):
+ # get local height only once, as it's relatively expensive.
+ # take care that nested calls work as expected
+ def f(self, *args, **kwargs):
+ orig_val = getattr(self.threadlocal_cache, 'local_height', None)
+ self.threadlocal_cache.local_height = orig_val or self.get_local_height()
+ try:
+ return func(self, *args, **kwargs)
+ finally:
+ self.threadlocal_cache.local_height = orig_val
+ return f
+
+ @with_lock
+ @with_local_height_cached
+ def get_history(self, domain) -> Sequence[HistoryItem]:
+ 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) # type: Dict[str, int]
+ for addr in domain:
+ h = self.get_address_history(addr).items()
+ for tx_hash, height in h:
+ tx_deltas[tx_hash] += self.get_tx_delta(tx_hash, addr)
+ # 2. create sorted history
+ history = []
+ for tx_hash in tx_deltas:
+ delta = tx_deltas[tx_hash]
+ tx_mined_status = self.get_tx_height(tx_hash)
+ fee = self.get_tx_fee(tx_hash)
+ history.append((tx_hash, tx_mined_status, delta, fee))
+ history.sort(key = lambda x: self._get_tx_sort_key(x[0]))
+ # 3. add balance
+ h2 = []
+ balance = 0
+ for tx_hash, tx_mined_status, delta, fee in history:
+ balance += delta
+ h2.append(HistoryItem(
+ txid=tx_hash,
+ tx_mined_status=tx_mined_status,
+ delta=delta,
+ fee=fee,
+ balance=balance))
+ # sanity check
+ c, u, x = self.get_balance(domain)
+ if balance != c + u + x:
+ self.logger.error(f'sanity check failed! c={c},u={u},x={x} while history balance={balance}')
+ raise Exception("wallet.get_history() failed balance sanity-check")
+ return h2
+
+ @with_lock
+ def _add_tx_to_local_history(self, txid):
+ for addr in itertools.chain(self.db.get_txi_addresses(txid), self.db.get_txo_addresses(txid)):
+ cur_hist = self._history_local.get(addr, set())
+ cur_hist.add(txid)
+ self._history_local[addr] = cur_hist
+ self._mark_address_history_changed(addr)
+
+ @with_lock
+ def _remove_tx_from_local_history(self, txid):
+ for addr in itertools.chain(self.db.get_txi_addresses(txid), self.db.get_txo_addresses(txid)):
+ cur_hist = self._history_local.get(addr, set())
+ try:
+ cur_hist.remove(txid)
+ except KeyError:
+ pass
+ else:
+ self._history_local[addr] = cur_hist
+ self._mark_address_history_changed(addr)
+
+ def _mark_address_history_changed(self, addr: str) -> None:
+ def set_and_clear():
+ event = self._address_history_changed_events[addr]
+ # history for this address changed, wake up coroutines:
+ event.set()
+ # clear event immediately so that coroutines can wait() for the next change:
+ event.clear()
+ if self.asyncio_loop:
+ self.asyncio_loop.call_soon_threadsafe(set_and_clear)
+
+ async def wait_for_address_history_to_change(self, addr: str) -> None:
+ """Wait until the server tells us about a new transaction related to addr.
+
+ Unconfirmed and confirmed transactions are not distinguished, and so e.g. SPV
+ is not taken into account.
+ """
+ assert self.is_mine(addr), "address needs to be is_mine to be watched"
+ await self._address_history_changed_events[addr].wait()
+
+ @with_lock
+ def add_unverified_or_unconfirmed_tx(self, tx_hash: str, tx_height: int) -> None:
+ assert tx_height >= TX_HEIGHT_UNCONF_PARENT, f"got {tx_height=} for {tx_hash=}" # forbid local/future txs here
+ if self.db.is_in_verified_tx(tx_hash):
+ if tx_height <= 0:
+ # tx was previously SPV-verified but now in mempool (probably reorg)
+ self.db.remove_verified_tx(tx_hash)
+ self.unconfirmed_tx[tx_hash] = tx_height
+ if self.verifier:
+ self.verifier.remove_spv_proof_for_tx(tx_hash)
+ else:
+ if tx_height > 0:
+ self.unverified_tx[tx_hash] = tx_height
+ else:
+ self.unconfirmed_tx[tx_hash] = tx_height
+
+ @with_lock
+ def remove_unverified_tx(self, tx_hash: str, tx_height: int) -> None:
+ new_height = self.unverified_tx.get(tx_hash)
+ if new_height == tx_height:
+ self.unverified_tx.pop(tx_hash, None)
+
+ def add_verified_tx(self, tx_hash: str, info: TxMinedInfo):
+ # Remove from the unverified map and add to the verified map
+ with self.lock:
+ self.unverified_tx.pop(tx_hash, None)
+ self.db.add_verified_tx(tx_hash, info)
+ self.invalidate_cache()
+ util.trigger_callback('adb_added_verified_tx', self, tx_hash)
+
+ @with_lock
+ def get_unverified_txs(self) -> Dict[str, int]:
+ '''Returns a map from tx hash to transaction height'''
+ return dict(self.unverified_tx) # copy
+
+ def undo_verifications(self, blockchain: Blockchain, above_height: int) -> Set[str]:
+ '''Used by the verifier when a reorg has happened'''
+ txs = set()
+ with self.lock:
+ for tx_hash in self.db.list_verified_tx():
+ info = self.db.get_verified_tx(tx_hash)
+ tx_height = info._height
+ if tx_height > above_height:
+ header = blockchain.read_header(tx_height)
+ if not header or hash_header(header) != info.header_hash:
+ self.db.remove_verified_tx(tx_hash)
+ # NOTE: we should add these txns to self.unverified_tx,
+ # but with what height?
+ # If on the new fork after the reorg, the txn is at the
+ # same height, we will not get a status update for the
+ # address. If the txn is not mined or at a diff height,
+ # we should get a status update. Unless we put tx into
+ # unverified_tx, it will turn into local. So we put it
+ # into unverified_tx with the old height, and if we get
+ # a status update, that will overwrite it.
+ self.unverified_tx[tx_hash] = tx_height
+ txs.add(tx_hash)
+
+ for tx_hash in txs:
+ util.trigger_callback('adb_removed_verified_tx', self, tx_hash)
+ return txs
+
+ def get_local_height(self) -> int:
+ """ return last known height if we are offline """
+ cached_local_height = getattr(self.threadlocal_cache, 'local_height', None)
+ if cached_local_height is not None:
+ return cached_local_height
+ return self.network.get_local_height() if self.network else self.db.get('stored_height', 0)
+
+ def _update_stored_local_height(self) -> None:
+ self.db.put('stored_height', self.get_local_height())
+
+ def set_future_tx(self, txid: str, *, wanted_height: int):
+ """Mark a local tx as "future" (encumbered by a timelock).
+ wanted_height is the min (abs) block height at which the tx can get into the mempool (be broadcast).
+ note: tx becomes consensus-valid to be mined in a block at height wanted_height+1
+ In case of a CSV-locked tx with unconfirmed inputs, the wanted_height is a best-case guess.
+ """
+ with self.lock:
+ old_height = self.future_tx.get(txid) or None
+ self.future_tx[txid] = wanted_height
+ if old_height != wanted_height:
+ util.trigger_callback('adb_set_future_tx', self, txid)
+
+ def get_tx_height(
+ self,
+ tx_hash: str,
+ *,
+ force_local_if_missing_tx: bool = True,
+ ) -> TxMinedInfo:
+ if tx_hash is None: # ugly backwards compat...
+ return TxMinedInfo(_height=TX_HEIGHT_LOCAL, conf=0)
+ with self.lock:
+ if verified_tx_mined_info := self.db.get_verified_tx(tx_hash): # mined and spv-ed
+ conf = max(self.get_local_height() - verified_tx_mined_info._height + 1, 0)
+ tx_mined_info = dataclasses.replace(verified_tx_mined_info, conf=conf)
+ elif tx_hash in self.unverified_tx: # mined, no spv
+ height = self.unverified_tx[tx_hash]
+ tx_mined_info = TxMinedInfo(_height=height, conf=0)
+ elif tx_hash in self.unconfirmed_tx: # mempool
+ height = self.unconfirmed_tx[tx_hash]
+ tx_mined_info = TxMinedInfo(_height=height, conf=0)
+ elif wanted_height := self.future_tx.get(tx_hash): # future
+ if wanted_height > self.get_local_height():
+ tx_mined_info = TxMinedInfo(_height=TX_HEIGHT_FUTURE, conf=0, wanted_height=wanted_height)
+ else:
+ tx_mined_info = TxMinedInfo(_height=TX_HEIGHT_LOCAL, conf=0)
+ else: # local
+ tx_mined_info = TxMinedInfo(_height=TX_HEIGHT_LOCAL, conf=0)
+ if tx_mined_info.height() in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE):
+ return tx_mined_info
+ if force_local_if_missing_tx:
+ # It can happen for a txid in any state (unconf/unverified/verified) that we
+ # don't have the raw tx yet, simply due to network timing.
+ # Having only a partial tx is another variant of this.
+ # FIXME in fact even if we have a complete tx saved, the server might have
+ # a different tx if only the witness differs. We should compare wtxids.
+ tx = self.db.get_transaction(tx_hash)
+ if tx is None or isinstance(tx, PartialTransaction):
+ return TxMinedInfo(_height=TX_HEIGHT_LOCAL, conf=0)
+ return tx_mined_info
+
+ def up_to_date_changed(self) -> None:
+ # fire triggers
+ util.trigger_callback('adb_set_up_to_date', self)
+
+ def is_up_to_date(self):
+ if not self.synchronizer or not self.verifier:
+ return False
+ return self.synchronizer.is_up_to_date() and self.verifier.is_up_to_date()
+
+ def reset_netrequest_counters(self) -> None:
+ if self.synchronizer:
+ self.synchronizer.reset_request_counters()
+ if self.verifier:
+ self.verifier.reset_request_counters()
+
+ def get_history_sync_state_details(self) -> Tuple[int, int]:
+ nsent, nans = 0, 0
+ if self.synchronizer:
+ n1, n2 = self.synchronizer.num_requests_sent_and_answered()
+ nsent += n1
+ nans += n2
+ if self.verifier:
+ n1, n2 = self.verifier.num_requests_sent_and_answered()
+ nsent += n1
+ nans += n2
+ return nsent, nans
+
+ @with_lock
+ def get_tx_delta(self, tx_hash: str, address: str) -> int:
+ """effect of tx on address"""
+ delta = 0
+ # subtract the value of coins sent from address
+ d = self.db.get_txi_addr(tx_hash, address)
+ for n, v in d:
+ delta -= v
+ # add the value of the coins received at address
+ d = self.db.get_txo_addr(tx_hash, address)
+ for n, (v, cb) in d.items():
+ delta += v
+ return delta
+
+ @with_lock
+ def get_tx_fee(self, txid: str) -> Optional[int]:
+ """Returns tx_fee or None. Use server fee only if tx is unconfirmed and not mine.
+
+ Note: being fast is prioritised over completeness here. We try to avoid deserializing
+ the tx, as that is expensive if we are called for the whole history. We sometimes
+ incorrectly early-exit and return None, e.g. for not-all-ismine-input txs,
+ where we could calculate the fee if we deserialized (but to see if we have all
+ the parent txs available, we would have to deserialize first).
+ More expensive but more complete alternative: wallet.get_tx_info(tx).fee
+ """
+ # check if stored fee is available
+ fee = self.db.get_tx_fee(txid, trust_server=False)
+ if fee is not None:
+ return fee
+ # delete server-sent fee for confirmed txns
+ confirmed = self.get_tx_height(txid).conf > 0
+ if confirmed:
+ self.db.add_tx_fee_from_server(txid, None)
+ # if all inputs are ismine, try to calc fee now;
+ # otherwise, return stored value
+ num_all_inputs = self.db.get_num_all_inputs_of_tx(txid)
+ if num_all_inputs is not None:
+ # check if tx is mine
+ num_ismine_inputs = self.db.get_num_ismine_inputs_of_tx(txid)
+ assert num_ismine_inputs <= num_all_inputs, (num_ismine_inputs, num_all_inputs)
+ # trust server if tx is unconfirmed and not mine
+ if num_ismine_inputs < num_all_inputs:
+ return None if confirmed else self.db.get_tx_fee(txid, trust_server=True)
+ # lookup tx and deserialize it.
+ # note that deserializing is expensive, hence above hacks
+ tx = self.db.get_transaction(txid)
+ if not tx:
+ return None
+ # compute fee if possible
+ v_in = v_out = 0
+ for txin in tx.inputs():
+ addr = self.get_txin_address(txin)
+ value = self.get_txin_value(txin, address=addr)
+ if value is None:
+ v_in = None
+ elif v_in is not None:
+ v_in += value
+ for txout in tx.outputs():
+ v_out += txout.value
+ if v_in is not None:
+ fee = v_in - v_out
+ else:
+ fee = None
+ # save result
+ self.db.add_tx_fee_we_calculated(txid, fee)
+ self.db.add_num_inputs_to_tx(txid, len(tx.inputs()))
+ return fee
+
+ @with_lock
+ def get_addr_io(self, address: str):
+ h = self.get_address_history(address).items()
+ received = {} # type: Dict[str, tuple[int, int, int, bool]]
+ sent = {} # type: Dict[str, tuple[str, int, int]]
+ for tx_hash, height in h:
+ tx_mined_info = self.get_tx_height(tx_hash)
+ txpos = tx_mined_info.txpos if tx_mined_info.txpos is not None else -1
+ d = self.db.get_txo_addr(tx_hash, address)
+ for n, (v, is_cb) in d.items():
+ received[tx_hash + ':%d'%n] = (height, txpos, v, is_cb)
+ l = self.db.get_txi_addr(tx_hash, address)
+ for txi, v in l:
+ sent[txi] = tx_hash, height, txpos
+ return received, sent
+
+ def get_addr_outputs(self, address: str) -> Dict[TxOutpoint, PartialTxInput]:
+ received, sent = self.get_addr_io(address)
+ out = {}
+ for prevout_str, v in received.items():
+ tx_height, tx_pos, value, is_cb = v
+ prevout = TxOutpoint.from_str(prevout_str)
+ utxo = PartialTxInput(prevout=prevout, is_coinbase_output=is_cb)
+ utxo._trusted_address = address
+ utxo._trusted_value_sats = value
+ utxo.block_height = tx_height
+ utxo.block_txpos = tx_pos
+ if prevout_str in sent:
+ txid, height, pos = sent[prevout_str]
+ utxo.spent_txid = txid
+ utxo.spent_height = height
+ else:
+ utxo.spent_txid = None
+ utxo.spent_height = 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([value for height, pos, value, is_cb in received.values()])
+
+ @with_lock
+ @with_local_height_cached
+ def get_balance(self, domain, *, excluded_addresses: Set[str] = None,
+ excluded_coins: Set[str] = None) -> Tuple[int, int, int]:
+ """Return the balance of a set of addresses:
+ confirmed and matured, unconfirmed, unmatured
+ Note: intended for display-purposes. would need extreme care for "has enough funds" checks (see #8835)
+ """
+ if excluded_addresses is None:
+ excluded_addresses = set()
+ assert isinstance(excluded_addresses, set), f"excluded_addresses should be set, not {type(excluded_addresses)}"
+ domain = set(domain) - excluded_addresses
+ if excluded_coins is None:
+ excluded_coins = set()
+ assert isinstance(excluded_coins, set), f"excluded_coins should be set, not {type(excluded_coins)}"
+
+ cache_key = sha256(','.join(sorted(domain)) + ';'
+ + ','.join(sorted(excluded_coins)))
+ cached_value = self._get_balance_cache.get(cache_key)
+ if cached_value:
+ return cached_value
+
+ coins = {}
+ for address in domain:
+ coins.update(self.get_addr_outputs(address))
+
+ c = u = x = 0
+ mempool_height = self.get_local_height() + 1 # height of next block
+ for utxo in coins.values(): # type: PartialTxInput
+ if utxo.spent_height is not None:
+ continue
+ if utxo.prevout.to_str() in excluded_coins:
+ continue
+ v = utxo.value_sats()
+ tx_height = utxo.block_height
+ is_cb = utxo.is_coinbase_output()
+ if is_cb and tx_height + COINBASE_MATURITY > mempool_height:
+ x += v
+ elif tx_height > 0:
+ c += v
+ else:
+ txid = utxo.prevout.txid.hex()
+ tx = self.db.get_transaction(txid)
+ assert tx is not None # txid comes from get_addr_io
+ # we look at the outputs that are spent by this transaction
+ # if those outputs are ours and confirmed, we count this coin as confirmed
+ confirmed_spent_amount = 0
+ for txin in tx.inputs():
+ if txin.prevout in coins:
+ coin = coins[txin.prevout]
+ if coin.block_height > 0:
+ confirmed_spent_amount += coin.value_sats()
+ # Compare amount, in case tx has confirmed and unconfirmed inputs, or is a coinjoin.
+ # (fixme: tx may have multiple change outputs)
+ if confirmed_spent_amount >= v:
+ c += v
+ else:
+ c += confirmed_spent_amount
+ u += v - confirmed_spent_amount
+ result = c, u, x
+ # cache result.
+ # Cache needs to be invalidated if a transaction is added to/
+ # removed from history; or on new blocks (maturity...)
+ self._get_balance_cache[cache_key] = result
+ return result
+
+ @with_lock
+ @with_local_height_cached
+ def get_utxos(
+ self,
+ domain,
+ *,
+ excluded_addresses=None,
+ mature_only: bool = False,
+ confirmed_funding_only: bool = False,
+ confirmed_spending_only: bool = False,
+ nonlocal_only: bool = False,
+ block_height: int = None,
+ ) -> Sequence[PartialTxInput]:
+ if block_height is not None:
+ # caller wants the UTXOs we had at a given height; check other parameters
+ assert confirmed_funding_only
+ assert confirmed_spending_only
+ assert nonlocal_only
+ else:
+ block_height = self.get_local_height()
+ coins = []
+ domain = set(domain)
+ if excluded_addresses:
+ domain = set(domain) - set(excluded_addresses)
+ mempool_height = block_height + 1 # height of next block
+ cache_key = sha256(
+ ','.join(sorted(domain))
+ + f";{mature_only};{confirmed_funding_only};{confirmed_spending_only};{nonlocal_only};{block_height}"
+ )
+ cached = self._get_utxos_cache.get(cache_key)
+ if cached is not None:
+ return copy.deepcopy(cached)
+ for addr in domain:
+ txos = self.get_addr_outputs(addr)
+ for txo in txos.values():
+ if txo.spent_height is not None:
+ if not confirmed_spending_only:
+ continue
+ if confirmed_spending_only and 0 < txo.spent_height <= block_height:
+ continue
+ if confirmed_funding_only and not (0 < txo.block_height <= block_height):
+ continue
+ if nonlocal_only and txo.block_height in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE):
+ continue
+ if (mature_only and txo.is_coinbase_output()
+ and txo.block_height + COINBASE_MATURITY > mempool_height):
+ continue
+ coins.append(txo)
+ continue
+ self._get_utxos_cache[cache_key] = copy.deepcopy(coins)
+ return coins
+
+ def is_used(self, address: str) -> bool:
+ """Whether any tx ever touched `address`."""
+ return self.get_address_history_len(address) != 0
+
+ def is_used_as_from_address(self, address: str) -> bool:
+ """Whether any tx ever spent from `address`."""
+ received, sent = self.get_addr_io(address)
+ return len(sent) > 0
+
+ def is_empty(self, address: str) -> bool:
+ coins = self.get_addr_utxo(address)
+ return not bool(coins)
+
+ @with_lock
+ @with_local_height_cached
+ def address_is_old(self, address: str, *, req_conf: int = 3) -> bool:
+ """Returns whether address has any history that is deeply confirmed.
+ Used for reorg-safe(ish) gap limit roll-forward.
+ """
+ max_conf = -1
+ h = self.db.get_addr_history(address)
+ needs_spv_check = not self.config.NETWORK_SKIPMERKLECHECK
+ for tx_hash, tx_height in h:
+ if needs_spv_check:
+ tx_age = self.get_tx_height(tx_hash).conf
+ else:
+ if tx_height <= 0:
+ tx_age = 0
+ else:
+ tx_age = self.get_local_height() - tx_height + 1
+ max_conf = max(max_conf, tx_age)
+ return max_conf >= req_conf
+
+ @with_lock
+ def get_spender(self, outpoint: str) -> Optional[str]:
+ """
+ returns txid spending outpoint.
+ """
+ prev_txid, index = outpoint.split(':')
+ spender_txid = self.db.get_spent_outpoint(prev_txid, int(index))
+ # discard local spenders
+ tx_mined_status = self.get_tx_height(spender_txid)
+ if tx_mined_status.height() in [TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE]:
+ spender_txid = None
+ return spender_txid
+
+ def subscribe_to_outputs(self, spender_txid: str):
+ spender_tx = self.get_transaction(spender_txid)
+ for i, o in enumerate(spender_tx.outputs()):
+ if o.address is None:
+ continue
+ if not self.is_mine(o.address):
+ self.add_address(o.address)
+
+ def get_tx_mined_depth(self, txid: str):
+ if not txid:
+ return TxMinedDepth.FREE
+ tx_mined_depth = self.get_tx_height(txid)
+ height, conf = tx_mined_depth.height(), tx_mined_depth.conf
+ if conf > 20: # FIXME unify with lnutil.REDEEM_AFTER_DOUBLE_SPENT_DELAY ?
+ return TxMinedDepth.DEEP
+ elif conf > 0:
+ return TxMinedDepth.SHALLOW
+ elif height in (TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_UNCONF_PARENT):
+ return TxMinedDepth.MEMPOOL
+ elif height in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE):
+ return TxMinedDepth.FREE
+ elif height > 0 and conf == 0:
+ # unverified but claimed to be mined
+ return TxMinedDepth.MEMPOOL
+ else:
+ raise NotImplementedError()
+
+ def is_deeply_mined(self, txid):
+ return self.get_tx_mined_depth(txid) == TxMinedDepth.DEEP
diff --git a/electrum/base_crash_reporter.py b/electrum/base_crash_reporter.py
new file mode 100644
index 000000000000..abdd11c5b30c
--- /dev/null
+++ b/electrum/base_crash_reporter.py
@@ -0,0 +1,266 @@
+# Electrum - lightweight Bitcoin client
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+import asyncio
+import json
+import locale
+import traceback
+import sys
+import queue
+from typing import TYPE_CHECKING, NamedTuple, Optional, TypedDict
+from types import TracebackType
+
+from .version import ELECTRUM_VERSION
+from . import constants
+from .i18n import _
+from .util import make_aiohttp_session, error_text_str_to_safe_str
+from .logging import describe_os_version, Logger, get_git_version
+from .crypto import sha256
+
+if TYPE_CHECKING:
+ from .network import ProxySettings
+
+
+_tainted_by_console = False
+def taint_reports_by_console_usage():
+ global _tainted_by_console
+ _tainted_by_console = True
+
+
+class CrashReportResponse(NamedTuple):
+ status: Optional[str]
+ text: str
+ url: Optional[str]
+
+
+class BaseCrashReporter(Logger):
+ report_server = "https://crashhub.electrum.org"
+ issue_template = """
Traceback
+
+{traceback}
+
+
+
Additional information
+
+
Electrum version: {app_version}
+
Python version: {python_version}
+
Operating system: {os}
+
Wallet type: {wallet_type}
+
Locale: {locale}
+
+ """
+ CRASH_MESSAGE = _('Something went wrong while executing Electrum.')
+ CRASH_TITLE = _('Sorry!')
+ REQUEST_HELP_MESSAGE = _('To help us diagnose and fix the problem, you can send us a bug report that contains '
+ 'useful debug information:')
+ DESCRIBE_ERROR_MESSAGE = _("Please briefly describe what led to the error (optional):")
+ ASK_CONFIRM_SEND = _("Do you want to send this report?")
+ USER_COMMENT_PLACEHOLDER = _("Do not enter sensitive/private information here. "
+ "The report will be visible on the public issue tracker.")
+
+ exc_args: tuple[type[BaseException], BaseException, TracebackType | None]
+
+ def __init__(
+ self,
+ exctype: type[BaseException],
+ excvalue: BaseException,
+ tb: TracebackType | None,
+ ):
+ Logger.__init__(self)
+ self.exc_args = (exctype, excvalue, tb)
+
+ def send_report(self, asyncio_loop, proxy: 'ProxySettings', *, timeout=None) -> CrashReportResponse:
+ # FIXME the caller needs to catch generic "Exception", as this method does not have a well-defined API...
+ bitcoinlike_genesis = constants.net.GENESIS[-4:] in [
+ "e26f", # mainnet
+ "4943", # testnet 3
+ "f043", # testnet 4
+ "1ef6", # signet
+ ]
+ is_forked_codebase = not bitcoinlike_genesis or "/spesmilo/" not in constants.GIT_REPO_URL
+ if is_forked_codebase and ".electrum.org" in BaseCrashReporter.report_server:
+ # Some kind of altcoin wants to send us crash reports... ?
+ # - You, the reader, yes *you*: if you forked the codebase e.g. to support an altcoin,
+ # please consider that upstream Electrum does NOT want to receive automated crash reports
+ # from your users. You should set up your own crash report server,
+ # and change `BaseCrashReporter.report_server` accordingly, or just disable reporting.
+ raise Exception(_("Missing report URL."))
+ report = self.get_traceback_info(*self.exc_args)
+ report.update(self.get_additional_info())
+ report = json.dumps(report)
+ coro = self.do_post(proxy, BaseCrashReporter.report_server + "/crash.json", data=report)
+ response = asyncio.run_coroutine_threadsafe(coro, asyncio_loop).result(timeout)
+ self.logger.info(
+ f"Crash report sent. Got response [DO NOT TRUST THIS MESSAGE]: {error_text_str_to_safe_str(response)}")
+ response = json.loads(response)
+ assert isinstance(response, dict), type(response)
+ # sanitize URL
+ if location := response.get("location"):
+ assert isinstance(location, str)
+ base_issues_url = constants.GIT_REPO_ISSUES_URL
+ if not base_issues_url.endswith("/"):
+ base_issues_url = base_issues_url + "/"
+ if not location.startswith(base_issues_url):
+ location = None
+ ret = CrashReportResponse(
+ status=response.get("status"),
+ url=location,
+ text=_("Thanks for reporting this issue!"),
+ )
+ return ret
+
+ async def do_post(self, proxy: 'ProxySettings', url, data) -> str:
+ async with make_aiohttp_session(proxy) as session:
+ async with session.post(url, data=data, raise_for_status=True) as resp:
+ return await resp.text()
+
+ @classmethod
+ def get_traceback_info(
+ cls,
+ exctype: type[BaseException],
+ excvalue: BaseException,
+ tb: TracebackType | None,
+ ) -> TypedDict('TBInfo', {'exc_string': str, 'stack': str, 'id': dict[str, str]}):
+ exc_string = str(excvalue)
+ stack = traceback.extract_tb(tb)
+ readable_trace = cls._get_traceback_str_to_send(exctype, excvalue, tb)
+ _id = {
+ "file": stack[-1].filename if len(stack) else '',
+ "name": stack[-1].name if len(stack) else '',
+ "type": exctype.__name__
+ } # note: this is the "id" the crash reporter server uses to group together reports.
+ return {
+ "exc_string": exc_string,
+ "stack": readable_trace,
+ "id": _id,
+ }
+
+ @classmethod
+ def get_traceback_groupid_hash(
+ cls,
+ exctype: type[BaseException],
+ excvalue: BaseException,
+ tb: TracebackType | None,
+ ) -> bytes:
+ tb_info = cls.get_traceback_info(exctype, excvalue, tb)
+ _id = tb_info["id"]
+ return sha256(str(_id))
+
+ def get_additional_info(self):
+ app_version = (get_git_version() or ELECTRUM_VERSION)
+ if _tainted_by_console:
+ app_version += "-consoletaint"
+ args = {
+ "app_version": app_version,
+ "python_version": sys.version,
+ "os": describe_os_version(),
+ "wallet_type": "unknown",
+ "locale": locale.getlocale()[0] or "?",
+ "description": self.get_user_description()
+ }
+ try:
+ args["wallet_type"] = self.get_wallet_type()
+ except Exception:
+ # Maybe the wallet isn't loaded yet
+ pass
+ return args
+
+ @classmethod
+ def _get_traceback_str_to_send(
+ cls,
+ exctype: type[BaseException],
+ excvalue: BaseException,
+ tb: TracebackType | None,
+ ) -> str:
+ # make sure that traceback sent to crash reporter contains
+ # e.__context__ and e.__cause__, i.e. if there was a chain of
+ # exceptions, we want the full traceback for the whole chain.
+ return "".join(traceback.format_exception(exctype, excvalue, tb))
+
+ def _get_traceback_str_to_display(self) -> str:
+ # overridden in Qt subclass
+ return self._get_traceback_str_to_send(*self.exc_args)
+
+ def get_report_string(self):
+ info = self.get_additional_info()
+ info["traceback"] = self._get_traceback_str_to_display()
+ return self.issue_template.format(**info)
+
+ def get_user_description(self):
+ raise NotImplementedError
+
+ def get_wallet_type(self) -> str:
+ raise NotImplementedError
+
+
+class EarlyExceptionsQueue:
+ """Helper singleton for explicitly sending exceptions to crash reporter.
+
+ Typically the GUIs set up an "exception hook" that catches all otherwise
+ uncaught exceptions (which unroll the stack of a thread completely).
+ This class provides methods to report *any* exception, and queueing logic
+ that delays processing until the exception hook is set up.
+ """
+
+ _is_exc_hook_ready = False
+ _exc_queue = queue.Queue()
+
+ @classmethod
+ def set_hook_as_ready(cls):
+ """Flush the queue and disable it for future exceptions."""
+ if cls._is_exc_hook_ready:
+ return
+ cls._is_exc_hook_ready = True
+ while cls._exc_queue.qsize() > 0:
+ e = cls._exc_queue.get()
+ cls._send_exception_to_crash_reporter(e)
+
+ @classmethod
+ def send_exception_to_crash_reporter(cls, e: BaseException):
+ if cls._is_exc_hook_ready:
+ cls._send_exception_to_crash_reporter(e)
+ else:
+ cls._exc_queue.put(e)
+
+ @staticmethod
+ def _send_exception_to_crash_reporter(e: BaseException):
+ assert EarlyExceptionsQueue._is_exc_hook_ready
+ sys.excepthook(type(e), e, e.__traceback__)
+
+
+send_exception_to_crash_reporter = EarlyExceptionsQueue.send_exception_to_crash_reporter
+
+
+def trigger_crash():
+ # note: do not change the type of the exception, the message,
+ # or the name of this method. All reports generated through this
+ # method will be grouped together by the crash reporter, and thus
+ # don't spam the issue tracker.
+
+ class TestingException(Exception):
+ pass
+
+ def crash_test():
+ raise TestingException("triggered crash for testing purposes")
+
+ import threading
+ t = threading.Thread(target=crash_test)
+ t.start()
diff --git a/electrum/bip21.py b/electrum/bip21.py
new file mode 100644
index 000000000000..3db495e7b54b
--- /dev/null
+++ b/electrum/bip21.py
@@ -0,0 +1,137 @@
+import urllib
+import urllib.parse
+import re
+from decimal import Decimal
+from typing import Optional
+
+from . import bitcoin
+from .util import format_satoshis_plain
+from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
+from .bolt11 import decode_bolt11_invoice, BOLT11DecodeException
+
+# note: when checking against these, use .lower() to support case-insensitivity
+BITCOIN_BIP21_URI_SCHEME = 'bitcoin'
+LIGHTNING_URI_SCHEME = 'lightning'
+
+# note: URI scheme handler registrations are duplicated all over the codebase:
+# - for Android: contrib/android/bitcoin_intent.xml
+# - for Linux Desktop: electrum.desktop
+# - for Windows (setup.exe): contrib/build-wine/electrum.nsi
+# - for macOS: contrib/osx/pyinstaller.spec
+
+
+class InvalidBitcoinURI(Exception):
+ pass
+
+
+def parse_bip21_URI(uri: str) -> dict:
+ """Raises InvalidBitcoinURI on malformed URI."""
+
+ if not isinstance(uri, str):
+ raise InvalidBitcoinURI(f"expected string, not {repr(uri)}")
+
+ if ':' not in uri:
+ if not bitcoin.is_address(uri):
+ raise InvalidBitcoinURI("Not a bitcoin address")
+ return {'address': uri}
+
+ u = urllib.parse.urlparse(uri)
+ if u.scheme.lower() != BITCOIN_BIP21_URI_SCHEME:
+ raise InvalidBitcoinURI("Not a bitcoin URI")
+ address = u.path
+
+ # python for android fails to parse query
+ if address.find('?') > 0:
+ address, query = u.path.split('?')
+ pq = urllib.parse.parse_qs(query)
+ else:
+ pq = urllib.parse.parse_qs(u.query)
+
+ for k, v in pq.items():
+ if len(v) != 1:
+ raise InvalidBitcoinURI(f'Duplicate Key: {repr(k)}')
+ if k.startswith('req-'):
+ # we have no support for any req-* query parameters
+ raise InvalidBitcoinURI(f'Unsupported Key: {repr(k)}')
+
+ out = {k: v[0] for k, v in pq.items()}
+ if address:
+ if not bitcoin.is_address(address):
+ raise InvalidBitcoinURI(f"Invalid bitcoin address: {address}")
+ out['address'] = address
+ if 'amount' in out:
+ am = out['amount']
+ try:
+ m = re.match(r'([0-9.]+)X([0-9])', am)
+ if m:
+ k = int(m.group(2)) - 8
+ amount = Decimal(m.group(1)) * pow(Decimal(10), k)
+ else:
+ amount = Decimal(am) * COIN
+ if amount > TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN or amount <= 0:
+ raise InvalidBitcoinURI(f"amount is out-of-bounds: {amount!r} BTC")
+ out['amount'] = int(amount)
+ except Exception as e:
+ raise InvalidBitcoinURI(f"failed to parse 'amount' field: {repr(e)}") from e
+ if 'message' in out:
+ out['message'] = out['message']
+ out['memo'] = out['message']
+ if 'time' in out:
+ try:
+ out['time'] = int(out['time'])
+ except Exception as e:
+ raise InvalidBitcoinURI(f"failed to parse 'time' field: {repr(e)}") from e
+ if 'exp' in out:
+ try:
+ out['exp'] = int(out['exp'])
+ except Exception as e:
+ raise InvalidBitcoinURI(f"failed to parse 'exp' field: {repr(e)}") from e
+ if 'sig' in out:
+ try:
+ out['sig'] = bitcoin.base_decode(out['sig'], base=58).hex()
+ except Exception as e:
+ raise InvalidBitcoinURI(f"failed to parse 'sig' field: {repr(e)}") from e
+ if 'lightning' in out:
+ try:
+ lnaddr = decode_bolt11_invoice(out['lightning'])
+ except BOLT11DecodeException as e:
+ raise InvalidBitcoinURI(f"Failed to decode 'lightning' field: {e!r}") from e
+ amount_sat = out.get('amount')
+ if amount_sat:
+ # allow small leeway due to msat precision
+ if lnaddr.get_amount_sat() is None or abs(amount_sat - int(lnaddr.get_amount_sat())) > 1:
+ raise InvalidBitcoinURI("Inconsistent lightning field in bip21: amount")
+ address = out.get('address')
+ ln_fallback_addr = lnaddr.get_fallback_address()
+ if address and ln_fallback_addr:
+ if ln_fallback_addr != address:
+ raise InvalidBitcoinURI("Inconsistent lightning field in bip21: address")
+
+ return out
+
+
+def create_bip21_uri(addr, amount_sat: Optional[int], message: Optional[str],
+ *, extra_query_params: Optional[dict] = None) -> str:
+ if not bitcoin.is_address(addr):
+ return ""
+ if extra_query_params is None:
+ extra_query_params = {}
+ query = []
+ if amount_sat:
+ query.append('amount=%s' % format_satoshis_plain(amount_sat))
+ if message:
+ query.append('message=%s' % urllib.parse.quote(message))
+ for k, v in extra_query_params.items():
+ if not isinstance(k, str) or k != urllib.parse.quote(k):
+ raise Exception(f"illegal key for URI: {repr(k)}")
+ v = urllib.parse.quote(v)
+ query.append(f"{k}={v}")
+ p = urllib.parse.ParseResult(
+ scheme=BITCOIN_BIP21_URI_SCHEME,
+ netloc='',
+ path=addr,
+ params='',
+ query='&'.join(query),
+ fragment=''
+ )
+ return str(urllib.parse.urlunparse(p))
diff --git a/electrum/bip32.py b/electrum/bip32.py
new file mode 100644
index 000000000000..6d36322f417a
--- /dev/null
+++ b/electrum/bip32.py
@@ -0,0 +1,534 @@
+# Copyright (C) 2018 The Electrum developers
+# Distributed under the MIT software license, see the accompanying
+# file LICENCE or http://www.opensource.org/licenses/mit-license.php
+
+import binascii
+import hashlib
+import struct
+from typing import List, Tuple, NamedTuple, Union, Iterable, Sequence, Optional
+
+import electrum_ecc as ecc
+
+from .util import bfh, BitcoinException
+from . import constants
+from .crypto import hash_160, hmac_oneshot
+from .bitcoin import EncodeBase58Check, DecodeBase58Check
+from .logging import get_logger
+
+
+_logger = get_logger(__name__)
+BIP32_PRIME = 0x80000000
+UINT32_MAX = (1 << 32) - 1
+
+BIP32_HARDENED_CHAR = "h" # default "hardened" char we put in str paths
+
+
+def protect_against_invalid_ecpoint(func):
+ def func_wrapper(*args):
+ child_index = args[-1]
+ while True:
+ is_prime = child_index & BIP32_PRIME
+ try:
+ return func(*args[:-1], child_index=child_index)
+ except ecc.InvalidECPointException:
+ _logger.warning('bip32 protect_against_invalid_ecpoint: skipping index')
+ child_index += 1
+ is_prime2 = child_index & BIP32_PRIME
+ if is_prime != is_prime2: raise OverflowError()
+ return func_wrapper
+
+
+@protect_against_invalid_ecpoint
+def CKD_priv(parent_privkey: bytes, parent_chaincode: bytes, child_index: int) -> Tuple[bytes, bytes]:
+ """Child private key derivation function (from master private key)
+ If n is hardened (i.e. the 32nd bit is set), the resulting private key's
+ corresponding public key can NOT be determined without the master private key.
+ However, if n is not hardened, the resulting private key's corresponding
+ public key can be determined without the master private key.
+ """
+ if child_index < 0: raise ValueError('the bip32 index needs to be non-negative')
+ is_hardened_child = bool(child_index & BIP32_PRIME)
+ return _CKD_priv(parent_privkey=parent_privkey,
+ parent_chaincode=parent_chaincode,
+ child_index=int.to_bytes(child_index, length=4, byteorder="big", signed=False),
+ is_hardened_child=is_hardened_child)
+
+
+def _CKD_priv(parent_privkey: bytes, parent_chaincode: bytes,
+ child_index: bytes, is_hardened_child: bool) -> Tuple[bytes, bytes]:
+ try:
+ keypair = ecc.ECPrivkey(parent_privkey)
+ except ecc.InvalidECPointException as e:
+ raise BitcoinException('Impossible xprv (not within curve order)') from e
+ parent_pubkey = keypair.get_public_key_bytes(compressed=True)
+ if is_hardened_child:
+ data = bytes([0]) + parent_privkey + child_index
+ else:
+ data = parent_pubkey + child_index
+ I = hmac_oneshot(parent_chaincode, data, hashlib.sha512)
+ I_left = ecc.string_to_number(I[0:32])
+ child_privkey = (I_left + ecc.string_to_number(parent_privkey)) % ecc.CURVE_ORDER
+ if I_left >= ecc.CURVE_ORDER or child_privkey == 0:
+ raise ecc.InvalidECPointException()
+ child_privkey = int.to_bytes(child_privkey, length=32, byteorder='big', signed=False)
+ child_chaincode = I[32:]
+ return child_privkey, child_chaincode
+
+
+
+@protect_against_invalid_ecpoint
+def CKD_pub(parent_pubkey: bytes, parent_chaincode: bytes, child_index: int) -> Tuple[bytes, bytes]:
+ """Child public key derivation function (from public key only)
+ This function allows us to find the nth public key, as long as n is
+ not hardened. If n is hardened, we need the master private key to find it.
+ """
+ if child_index < 0: raise ValueError('the bip32 index needs to be non-negative')
+ if child_index & BIP32_PRIME: raise Exception('not possible to derive hardened child from parent pubkey')
+ return _CKD_pub(parent_pubkey=parent_pubkey,
+ parent_chaincode=parent_chaincode,
+ child_index=int.to_bytes(child_index, length=4, byteorder="big", signed=False))
+
+
+# helper function, callable with arbitrary 'child_index' byte-string.
+# i.e.: 'child_index' does not need to fit into 32 bits here! (c.f. trustedcoin billing)
+def _CKD_pub(parent_pubkey: bytes, parent_chaincode: bytes, child_index: bytes) -> Tuple[bytes, bytes]:
+ I = hmac_oneshot(parent_chaincode, parent_pubkey + child_index, hashlib.sha512)
+ pubkey = ecc.ECPrivkey(I[0:32]) + ecc.ECPubkey(parent_pubkey)
+ if pubkey.is_at_infinity():
+ raise ecc.InvalidECPointException()
+ child_pubkey = pubkey.get_public_key_bytes(compressed=True)
+ child_chaincode = I[32:]
+ return child_pubkey, child_chaincode
+
+
+def xprv_header(xtype: str, *, net=None) -> bytes:
+ if net is None:
+ net = constants.net
+ return net.XPRV_HEADERS[xtype].to_bytes(length=4, byteorder="big")
+
+
+def xpub_header(xtype: str, *, net=None) -> bytes:
+ if net is None:
+ net = constants.net
+ return net.XPUB_HEADERS[xtype].to_bytes(length=4, byteorder="big")
+
+
+class InvalidMasterKeyVersionBytes(BitcoinException): pass
+
+
+class BIP32Node(NamedTuple):
+ xtype: str
+ eckey: Union[ecc.ECPubkey, ecc.ECPrivkey]
+ chaincode: bytes
+ depth: int = 0
+ fingerprint: bytes = b'\x00'*4 # as in serialized format, this is the *parent's* fingerprint
+ child_number: bytes = b'\x00'*4
+
+ @classmethod
+ def from_xkey(
+ cls,
+ xkey: str,
+ *,
+ net=None,
+ allow_custom_headers: bool = True, # to also accept ypub/zpub
+ ) -> 'BIP32Node':
+ if net is None:
+ net = constants.net
+ xkey = DecodeBase58Check(xkey)
+ if len(xkey) != 78:
+ raise BitcoinException('Invalid length for extended key: {}'
+ .format(len(xkey)))
+ depth = xkey[4]
+ fingerprint = xkey[5:9]
+ child_number = xkey[9:13]
+ chaincode = xkey[13:13 + 32]
+ header = int.from_bytes(xkey[0:4], byteorder='big')
+ if header in net.XPRV_HEADERS_INV:
+ headers_inv = net.XPRV_HEADERS_INV
+ is_private = True
+ elif header in net.XPUB_HEADERS_INV:
+ headers_inv = net.XPUB_HEADERS_INV
+ is_private = False
+ else:
+ raise InvalidMasterKeyVersionBytes(f'Invalid extended key format: {hex(header)}')
+ xtype = headers_inv[header]
+ if not allow_custom_headers and xtype != "standard":
+ raise ValueError(f"only standard xpub/xprv allowed. found custom xtype={xtype}")
+ if is_private:
+ eckey = ecc.ECPrivkey(xkey[13 + 33:])
+ else:
+ eckey = ecc.ECPubkey(xkey[13 + 32:])
+ return BIP32Node(xtype=xtype,
+ eckey=eckey,
+ chaincode=chaincode,
+ depth=depth,
+ fingerprint=fingerprint,
+ child_number=child_number)
+
+ @classmethod
+ def from_rootseed(cls, seed: bytes, *, xtype: str) -> 'BIP32Node':
+ I = hmac_oneshot(b"Bitcoin seed", seed, hashlib.sha512)
+ master_k = I[0:32]
+ master_c = I[32:]
+ return BIP32Node(xtype=xtype,
+ eckey=ecc.ECPrivkey(master_k),
+ chaincode=master_c)
+
+ @classmethod
+ def from_bytes(cls, b: bytes) -> 'BIP32Node':
+ if len(b) != 78:
+ raise Exception(f"unexpected xkey raw bytes len {len(b)} != 78")
+ xkey = EncodeBase58Check(b)
+ return cls.from_xkey(xkey)
+
+ def to_xprv(self, *, net=None) -> str:
+ payload = self.to_xprv_bytes(net=net)
+ return EncodeBase58Check(payload)
+
+ def to_xprv_bytes(self, *, net=None) -> bytes:
+ if not self.is_private():
+ raise Exception("cannot serialize as xprv; private key missing")
+ payload = (xprv_header(self.xtype, net=net) +
+ bytes([self.depth]) +
+ self.fingerprint +
+ self.child_number +
+ self.chaincode +
+ bytes([0]) +
+ self.eckey.get_secret_bytes())
+ assert len(payload) == 78, f"unexpected xprv payload len {len(payload)}"
+ return payload
+
+ def to_xpub(self, *, net=None) -> str:
+ payload = self.to_xpub_bytes(net=net)
+ return EncodeBase58Check(payload)
+
+ def to_xpub_bytes(self, *, net=None) -> bytes:
+ payload = (xpub_header(self.xtype, net=net) +
+ bytes([self.depth]) +
+ self.fingerprint +
+ self.child_number +
+ self.chaincode +
+ self.eckey.get_public_key_bytes(compressed=True))
+ assert len(payload) == 78, f"unexpected xpub payload len {len(payload)}"
+ return payload
+
+ def to_xkey(self, *, net=None) -> str:
+ if self.is_private():
+ return self.to_xprv(net=net)
+ else:
+ return self.to_xpub(net=net)
+
+ def to_bytes(self, *, net=None) -> bytes:
+ if self.is_private():
+ return self.to_xprv_bytes(net=net)
+ else:
+ return self.to_xpub_bytes(net=net)
+
+ def convert_to_public(self) -> 'BIP32Node':
+ if not self.is_private():
+ return self
+ pubkey = ecc.ECPubkey(self.eckey.get_public_key_bytes())
+ return self._replace(eckey=pubkey)
+
+ def is_private(self) -> bool:
+ return isinstance(self.eckey, ecc.ECPrivkey)
+
+ def subkey_at_private_derivation(self, path: Union[str, Iterable[int]]) -> 'BIP32Node':
+ if path is None:
+ raise Exception("derivation path must not be None")
+ if isinstance(path, str):
+ path = convert_bip32_strpath_to_intpath(path)
+ if not self.is_private():
+ raise Exception("cannot do bip32 private derivation; private key missing")
+ if not path:
+ return self
+ depth = self.depth
+ chaincode = self.chaincode
+ privkey = self.eckey.get_secret_bytes()
+ for child_index in path:
+ parent_privkey = privkey
+ privkey, chaincode = CKD_priv(privkey, chaincode, child_index)
+ depth += 1
+ parent_pubkey = ecc.ECPrivkey(parent_privkey).get_public_key_bytes(compressed=True)
+ fingerprint = hash_160(parent_pubkey)[0:4]
+ child_number = child_index.to_bytes(length=4, byteorder="big")
+ return BIP32Node(xtype=self.xtype,
+ eckey=ecc.ECPrivkey(privkey),
+ chaincode=chaincode,
+ depth=depth,
+ fingerprint=fingerprint,
+ child_number=child_number)
+
+ def subkey_at_public_derivation(self, path: Union[str, Iterable[int]]) -> 'BIP32Node':
+ if path is None:
+ raise Exception("derivation path must not be None")
+ if isinstance(path, str):
+ path = convert_bip32_strpath_to_intpath(path)
+ if not path:
+ return self.convert_to_public()
+ depth = self.depth
+ chaincode = self.chaincode
+ pubkey = self.eckey.get_public_key_bytes(compressed=True)
+ for child_index in path:
+ parent_pubkey = pubkey
+ pubkey, chaincode = CKD_pub(pubkey, chaincode, child_index)
+ depth += 1
+ fingerprint = hash_160(parent_pubkey)[0:4]
+ child_number = child_index.to_bytes(length=4, byteorder="big")
+ return BIP32Node(xtype=self.xtype,
+ eckey=ecc.ECPubkey(pubkey),
+ chaincode=chaincode,
+ depth=depth,
+ fingerprint=fingerprint,
+ child_number=child_number)
+
+ def calc_fingerprint_of_this_node(self) -> bytes:
+ """Returns the fingerprint of this node.
+ Note that self.fingerprint is of the *parent*.
+ """
+ # TODO cache this
+ return hash_160(self.eckey.get_public_key_bytes(compressed=True))[0:4]
+
+
+def xpub_type(x: str) -> str:
+ assert x is not None
+ return BIP32Node.from_xkey(x).xtype
+
+
+def is_xpub(text: str) -> bool:
+ try:
+ node = BIP32Node.from_xkey(text)
+ return not node.is_private()
+ except Exception:
+ return False
+
+
+def is_xprv(text: str) -> bool:
+ try:
+ node = BIP32Node.from_xkey(text)
+ return node.is_private()
+ except Exception:
+ return False
+
+
+def xpub_from_xprv(xprv: str) -> str:
+ return BIP32Node.from_xkey(xprv).to_xpub()
+
+
+def convert_bip32_strpath_to_intpath(n: str) -> List[int]:
+ """Convert bip32 path str to list of uint32 integers with prime flags
+ m/0/-1/1' -> [0, 0x80000001, 0x80000001]
+
+ based on code in trezorlib
+ """
+ if not n:
+ return []
+ if n.endswith("/"):
+ n = n[:-1]
+ n = n.split('/')
+ # cut leading "m" if present, but do not require it
+ if n[0] == "m":
+ n = n[1:]
+ path = []
+ for x in n:
+ if x == '':
+ # gracefully allow repeating "/" chars in path.
+ # makes concatenating paths easier
+ continue
+ prime = 0
+ if x.endswith("'") or x.endswith("h"): # note: some implementations also accept "H", "p", "P"
+ x = x[:-1]
+ prime = BIP32_PRIME
+ if x.startswith('-'):
+ if prime:
+ raise ValueError(f"bip32 path child index is signalling hardened level in multiple ways")
+ prime = BIP32_PRIME
+ try:
+ x_int = int(x)
+ except ValueError as e:
+ raise ValueError(f"failed to parse bip32 path: {(str(e))}") from None
+ child_index = abs(x_int) | prime
+ if child_index > UINT32_MAX:
+ raise ValueError(f"bip32 path child index too large: {child_index} > {UINT32_MAX}")
+ path.append(child_index)
+ return path
+
+
+def convert_bip32_intpath_to_strpath(path: Sequence[int], *, hardened_char=BIP32_HARDENED_CHAR) -> str:
+ assert isinstance(hardened_char, str), hardened_char
+ assert len(hardened_char) == 1, hardened_char
+ s = "m/"
+ for child_index in path:
+ if not isinstance(child_index, int):
+ raise TypeError(f"bip32 path child index must be int: {child_index}")
+ if not (0 <= child_index <= UINT32_MAX):
+ raise ValueError(f"bip32 path child index out of range: {child_index}")
+ prime = ""
+ if child_index & BIP32_PRIME:
+ prime = hardened_char
+ child_index = child_index ^ BIP32_PRIME
+ s += str(child_index) + prime + '/'
+ # cut trailing "/"
+ s = s[:-1]
+ return s
+
+
+def is_bip32_derivation(s: str) -> bool:
+ try:
+ if not (s == 'm' or s.startswith('m/')):
+ return False
+ convert_bip32_strpath_to_intpath(s)
+ except Exception:
+ return False
+ else:
+ return True
+
+
+def normalize_bip32_derivation(s: Optional[str], *, hardened_char=BIP32_HARDENED_CHAR) -> Optional[str]:
+ if s is None:
+ return None
+ if not is_bip32_derivation(s):
+ raise ValueError(f"invalid bip32 derivation: {s}")
+ ints = convert_bip32_strpath_to_intpath(s)
+ return convert_bip32_intpath_to_strpath(ints, hardened_char=hardened_char)
+
+
+def is_all_public_derivation(path: Union[str, Iterable[int]]) -> bool:
+ """Returns whether all levels in path use non-hardened derivation."""
+ if isinstance(path, str):
+ path = convert_bip32_strpath_to_intpath(path)
+ for child_index in path:
+ if child_index < 0:
+ raise ValueError('the bip32 index needs to be non-negative')
+ if child_index & BIP32_PRIME:
+ return False
+ return True
+
+
+def root_fp_and_der_prefix_from_xkey(xkey: str) -> Tuple[Optional[str], Optional[str]]:
+ """Returns the root bip32 fingerprint and the derivation path from the
+ root to the given xkey, if they can be determined. Otherwise (None, None).
+ """
+ node = BIP32Node.from_xkey(xkey)
+ derivation_prefix = None
+ root_fingerprint = None
+ assert node.depth >= 0, node.depth
+ if node.depth == 0:
+ derivation_prefix = 'm'
+ root_fingerprint = node.calc_fingerprint_of_this_node().hex().lower()
+ elif node.depth == 1:
+ child_number_int = int.from_bytes(node.child_number, 'big')
+ 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_strpath_to_intpath(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
+
+
+class KeyOriginInfo:
+ """
+ Object representing the origin of a key.
+
+ from https://github.com/bitcoin-core/HWI/blob/5f300d3dee7b317a6194680ad293eaa0962a3cc7/hwilib/key.py
+ # Copyright (c) 2020 The HWI developers
+ # Distributed under the MIT software license.
+ """
+ def __init__(self, fingerprint: bytes, path: Sequence[int]) -> None:
+ """
+ :param fingerprint: The 4 byte BIP 32 fingerprint of a parent key from which this key is derived from
+ :param path: The derivation path to reach this key from the key at ``fingerprint``
+ """
+ self.fingerprint: bytes = fingerprint
+ self.path: Sequence[int] = path
+
+ @classmethod
+ def deserialize(cls, s: bytes) -> 'KeyOriginInfo':
+ """
+ Deserialize a serialized KeyOriginInfo.
+ They will be serialized in the same way that PSBTs serialize derivation paths
+ """
+ fingerprint = s[0:4]
+ s = s[4:]
+ path = list(struct.unpack("<" + "I" * (len(s) // 4), s))
+ return cls(fingerprint, path)
+
+ def serialize(self) -> bytes:
+ """
+ Serializes the KeyOriginInfo in the same way that derivation paths are stored in PSBTs
+ """
+ r = self.fingerprint
+ r += struct.pack("<" + "I" * len(self.path), *self.path)
+ return r
+
+ def _path_string(self) -> str:
+ strpath = self.get_derivation_path()
+ if len(strpath) >= 2:
+ assert strpath.startswith("m/")
+ return strpath[1:] # cut leading "m"
+
+ def to_string(self) -> str:
+ """
+ Return the KeyOriginInfo as a string in the form ///...
+ This is the same way that KeyOriginInfo is shown in descriptors
+ """
+ s = binascii.hexlify(self.fingerprint).decode()
+ s += self._path_string()
+ return s
+
+ @classmethod
+ def from_string(cls, s: str) -> 'KeyOriginInfo':
+ """
+ Create a KeyOriginInfo from the string
+ :param s: The string to parse
+ """
+ s = s.lower()
+ entries = s.split("/")
+ fingerprint = binascii.unhexlify(s[0:8])
+ path: Sequence[int] = []
+ if len(entries) > 1:
+ path = convert_bip32_strpath_to_intpath(s[9:])
+ return cls(fingerprint, path)
+
+ def get_derivation_path(self) -> str:
+ """
+ Return the string for just the path
+ """
+ return convert_bip32_intpath_to_strpath(self.path)
+
+ def get_full_int_list(self) -> List[int]:
+ """
+ Return a list of ints representing this KeyOriginInfo.
+ The first int is the fingerprint, followed by the path
+ """
+ xfp = [struct.unpack(" bool:
+ if not isinstance(other, KeyOriginInfo):
+ return False
+ return self.serialize() == other.serialize()
+
+ def __repr__(self) -> str:
+ return f""
diff --git a/electrum/bip39_recovery.py b/electrum/bip39_recovery.py
new file mode 100644
index 000000000000..3b857b3ebc19
--- /dev/null
+++ b/electrum/bip39_recovery.py
@@ -0,0 +1,81 @@
+# 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, Optional
+import itertools
+
+from . import bitcoin
+from .constants import BIP39_WALLET_FORMATS
+from .bip32 import BIP32_PRIME, BIP32Node
+from .bip32 import convert_bip32_strpath_to_intpath as bip32_str_to_ints
+from .bip32 import convert_bip32_intpath_to_strpath as bip32_ints_to_str
+from .util import OldTaskGroup, NetworkOfflineException
+
+if TYPE_CHECKING:
+ from .network import Network
+
+
+async def account_discovery(network: Optional['Network'], get_account_xpub):
+ if network is None:
+ raise NetworkOfflineException()
+ async with OldTaskGroup() 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:
+ # note: scan both receiving and change addresses. some wallets send change across accounts.
+ path_suffixes = itertools.chain(
+ itertools.product((0,), range(20)), # ad-hoc gap limits
+ itertools.product((1,), range(10)),
+ )
+ async with OldTaskGroup() as group:
+ get_history_tasks = []
+ for path_suffix in path_suffixes:
+ address_node = account_node.subkey_at_public_derivation(path_suffix)
+ pubkey = address_node.eckey.get_public_key_hex()
+ address = bitcoin.pubkey_to_address(script_type, pubkey)
+ script = bitcoin.address_to_script(address)
+ scripthash = bitcoin.script_to_scripthash(script)
+ get_history = network.get_history_for_scripthash(scripthash)
+ get_history_tasks.append(await group.spawn(get_history))
+ for task in get_history_tasks:
+ history = task.result()
+ if len(history) > 0:
+ return True
+ return False
+
+
+def format_account(wallet_format, account_path):
+ description = wallet_format["description"]
+ if wallet_format["iterate_accounts"]:
+ account_index = account_path[-1] % BIP32_PRIME
+ description = f'{description} (Account {account_index})'
+ return {
+ "description": description,
+ "derivation_path": bip32_ints_to_str(account_path),
+ "script_type": wallet_format["script_type"],
+ }
diff --git a/electrum/bip39_wallet_formats.json b/electrum/bip39_wallet_formats.json
new file mode 100644
index 000000000000..dc0d77af2da4
--- /dev/null
+++ b/electrum/bip39_wallet_formats.json
@@ -0,0 +1,110 @@
+[
+ {
+ "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": "Non-standard legacy on BIP84 path",
+ "derivation_path": "m/84'/0'/0'",
+ "script_type": "p2pkh",
+ "iterate_accounts": true
+ },
+ {
+ "description": "Non-standard compatibility segwit on BIP84 path",
+ "derivation_path": "m/84'/0'/0'",
+ "script_type": "p2wpkh-p2sh",
+ "iterate_accounts": true
+ },
+ {
+ "description": "Non-standard legacy on BIP49 path",
+ "derivation_path": "m/49'/0'/0'",
+ "script_type": "p2pkh",
+ "iterate_accounts": true
+ },
+ {
+ "description": "Non-standard native segwit on BIP49 path",
+ "derivation_path": "m/49'/0'/0'",
+ "script_type": "p2wpkh",
+ "iterate_accounts": true
+ },
+ {
+ "description": "Copay native segwit",
+ "derivation_path": "m/44'/0'/0'",
+ "script_type": "p2wpkh",
+ "iterate_accounts": true
+ },
+ {
+ "description": "Coolwallet S derivation path using bip44 but with segwit script format",
+ "derivation_path": "m/44'/0'/0'",
+ "script_type": "p2wpkh-p2sh",
+ "iterate_accounts": true
+ },
+ {
+ "description": "Samourai Bad Bank (toxic change)",
+ "derivation_path": "m/84'/0'/2147483644'",
+ "script_type": "p2wpkh",
+ "iterate_accounts": false
+ },
+ {
+ "description": "Samourai Whirlpool Pre Mix",
+ "derivation_path": "m/84'/0'/2147483645'",
+ "script_type": "p2wpkh",
+ "iterate_accounts": false
+ },
+ {
+ "description": "Samourai Whirlpool Post Mix",
+ "derivation_path": "m/84'/0'/2147483646'",
+ "script_type": "p2wpkh",
+ "iterate_accounts": false
+ },
+ {
+ "description": "Samourai Ricochet legacy",
+ "derivation_path": "m/44'/0'/2147483647'",
+ "script_type": "p2pkh",
+ "iterate_accounts": false
+ },
+ {
+ "description": "Samourai Ricochet compatibility segwit",
+ "derivation_path": "m/49'/0'/2147483647'",
+ "script_type": "p2wpkh-p2sh",
+ "iterate_accounts": false
+ },
+ {
+ "description": "Samourai Ricochet native segwit",
+ "derivation_path": "m/84'/0'/2147483647'",
+ "script_type": "p2wpkh",
+ "iterate_accounts": false
+ }
+]
diff --git a/electrum/bitcoin.py b/electrum/bitcoin.py
new file mode 100644
index 000000000000..7afde6e54c7c
--- /dev/null
+++ b/electrum/bitcoin.py
@@ -0,0 +1,905 @@
+# -*- coding: utf-8 -*-
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2011 thomasv@gitorious
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from typing import Tuple, TYPE_CHECKING, Optional, Union, Sequence, Mapping, Any
+import enum
+from enum import IntEnum, Enum
+
+import electrum_ecc as ecc
+from electrum_ecc.util import bip340_tagged_hash
+
+from .util import bfh, BitcoinException, assert_bytes, to_bytes, inv_dict, is_hex_str, classproperty
+from . import segwit_addr
+from . import constants
+from .crypto import sha256d, sha256, hash_160
+
+if TYPE_CHECKING:
+ from .network import Network
+ from .transaction import OPPushDataGeneric
+
+
+################################## transactions
+
+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
+TYPE_PUBKEY = 1
+TYPE_SCRIPT = 2
+
+
+class opcodes(IntEnum):
+ # push value
+ OP_0 = 0x00
+ OP_FALSE = OP_0
+ OP_PUSHDATA1 = 0x4c
+ OP_PUSHDATA2 = 0x4d
+ OP_PUSHDATA4 = 0x4e
+ OP_1NEGATE = 0x4f
+ OP_RESERVED = 0x50
+ OP_1 = 0x51
+ OP_TRUE = OP_1
+ OP_2 = 0x52
+ OP_3 = 0x53
+ OP_4 = 0x54
+ OP_5 = 0x55
+ OP_6 = 0x56
+ OP_7 = 0x57
+ OP_8 = 0x58
+ OP_9 = 0x59
+ OP_10 = 0x5a
+ OP_11 = 0x5b
+ OP_12 = 0x5c
+ OP_13 = 0x5d
+ OP_14 = 0x5e
+ OP_15 = 0x5f
+ OP_16 = 0x60
+
+ # control
+ OP_NOP = 0x61
+ OP_VER = 0x62
+ OP_IF = 0x63
+ OP_NOTIF = 0x64
+ OP_VERIF = 0x65
+ OP_VERNOTIF = 0x66
+ OP_ELSE = 0x67
+ OP_ENDIF = 0x68
+ OP_VERIFY = 0x69
+ OP_RETURN = 0x6a
+
+ # stack ops
+ OP_TOALTSTACK = 0x6b
+ OP_FROMALTSTACK = 0x6c
+ OP_2DROP = 0x6d
+ OP_2DUP = 0x6e
+ OP_3DUP = 0x6f
+ OP_2OVER = 0x70
+ OP_2ROT = 0x71
+ OP_2SWAP = 0x72
+ OP_IFDUP = 0x73
+ OP_DEPTH = 0x74
+ OP_DROP = 0x75
+ OP_DUP = 0x76
+ OP_NIP = 0x77
+ OP_OVER = 0x78
+ OP_PICK = 0x79
+ OP_ROLL = 0x7a
+ OP_ROT = 0x7b
+ OP_SWAP = 0x7c
+ OP_TUCK = 0x7d
+
+ # splice ops
+ OP_CAT = 0x7e
+ OP_SUBSTR = 0x7f
+ OP_LEFT = 0x80
+ OP_RIGHT = 0x81
+ OP_SIZE = 0x82
+
+ # bit logic
+ OP_INVERT = 0x83
+ OP_AND = 0x84
+ OP_OR = 0x85
+ OP_XOR = 0x86
+ OP_EQUAL = 0x87
+ OP_EQUALVERIFY = 0x88
+ OP_RESERVED1 = 0x89
+ OP_RESERVED2 = 0x8a
+
+ # numeric
+ OP_1ADD = 0x8b
+ OP_1SUB = 0x8c
+ OP_2MUL = 0x8d
+ OP_2DIV = 0x8e
+ OP_NEGATE = 0x8f
+ OP_ABS = 0x90
+ OP_NOT = 0x91
+ OP_0NOTEQUAL = 0x92
+
+ OP_ADD = 0x93
+ OP_SUB = 0x94
+ OP_MUL = 0x95
+ OP_DIV = 0x96
+ OP_MOD = 0x97
+ OP_LSHIFT = 0x98
+ OP_RSHIFT = 0x99
+
+ OP_BOOLAND = 0x9a
+ OP_BOOLOR = 0x9b
+ OP_NUMEQUAL = 0x9c
+ OP_NUMEQUALVERIFY = 0x9d
+ OP_NUMNOTEQUAL = 0x9e
+ OP_LESSTHAN = 0x9f
+ OP_GREATERTHAN = 0xa0
+ OP_LESSTHANOREQUAL = 0xa1
+ OP_GREATERTHANOREQUAL = 0xa2
+ OP_MIN = 0xa3
+ OP_MAX = 0xa4
+
+ OP_WITHIN = 0xa5
+
+ # crypto
+ OP_RIPEMD160 = 0xa6
+ OP_SHA1 = 0xa7
+ OP_SHA256 = 0xa8
+ OP_HASH160 = 0xa9
+ OP_HASH256 = 0xaa
+ OP_CODESEPARATOR = 0xab
+ OP_CHECKSIG = 0xac
+ OP_CHECKSIGVERIFY = 0xad
+ OP_CHECKMULTISIG = 0xae
+ OP_CHECKMULTISIGVERIFY = 0xaf
+
+ # expansion
+ OP_NOP1 = 0xb0
+ OP_CHECKLOCKTIMEVERIFY = 0xb1
+ OP_NOP2 = OP_CHECKLOCKTIMEVERIFY
+ OP_CHECKSEQUENCEVERIFY = 0xb2
+ OP_NOP3 = OP_CHECKSEQUENCEVERIFY
+ OP_NOP4 = 0xb3
+ OP_NOP5 = 0xb4
+ OP_NOP6 = 0xb5
+ OP_NOP7 = 0xb6
+ OP_NOP8 = 0xb7
+ OP_NOP9 = 0xb8
+ OP_NOP10 = 0xb9
+
+ OP_INVALIDOPCODE = 0xff
+
+ def hex(self) -> str:
+ return bytes([self]).hex()
+
+
+def script_num_to_bytes(i: int) -> bytes:
+ """See CScriptNum in Bitcoin Core.
+ Encodes an integer as bytes, to be used in script.
+
+ ported from https://github.com/bitcoin/bitcoin/blob/8cbc5c4be4be22aca228074f087a374a7ec38be8/src/script/script.h#L326
+ """
+ if i == 0:
+ return b""
+
+ result = bytearray()
+ neg = i < 0
+ absvalue = abs(i)
+ while absvalue > 0:
+ result.append(absvalue & 0xff)
+ absvalue >>= 8
+
+ if result[-1] & 0x80:
+ result.append(0x80 if neg else 0x00)
+ elif neg:
+ result[-1] |= 0x80
+
+ return bytes(result)
+
+
+def var_int(i: int) -> bytes:
+ # https://en.bitcoin.it/wiki/Protocol_specification#Variable_length_integer
+ # https://github.com/bitcoin/bitcoin/blob/efe1ee0d8d7f82150789f1f6840f139289628a2b/src/serialize.h#L247
+ # "CompactSize"
+ assert i >= 0, i
+ if i < 0xfd:
+ return int.to_bytes(i, length=1, byteorder="little", signed=False)
+ elif i <= 0xffff:
+ return b"\xfd" + int.to_bytes(i, length=2, byteorder="little", signed=False)
+ elif i <= 0xffffffff:
+ return b"\xfe" + int.to_bytes(i, length=4, byteorder="little", signed=False)
+ else:
+ return b"\xff" + int.to_bytes(i, length=8, byteorder="little", signed=False)
+
+
+def witness_push(item: bytes) -> bytes:
+ """Returns data in the form it should be present in the witness."""
+ return var_int(len(item)) + item
+
+
+def _op_push(i: int) -> bytes:
+ if i < opcodes.OP_PUSHDATA1:
+ return int.to_bytes(i, length=1, byteorder="little", signed=False)
+ elif i <= 0xff:
+ return bytes([opcodes.OP_PUSHDATA1]) + int.to_bytes(i, length=1, byteorder="little", signed=False)
+ elif i <= 0xffff:
+ return bytes([opcodes.OP_PUSHDATA2]) + int.to_bytes(i, length=2, byteorder="little", signed=False)
+ else:
+ return bytes([opcodes.OP_PUSHDATA4]) + int.to_bytes(i, length=4, byteorder="little", signed=False)
+
+
+def push_script(data: bytes) -> bytes:
+ """Returns pushed data to the script, automatically
+ choosing canonical opcodes depending on the length of the data.
+
+ ported from https://github.com/btcsuite/btcd/blob/fdc2bc867bda6b351191b5872d2da8270df00d13/txscript/scriptbuilder.go#L128
+ """
+ data_len = len(data)
+
+ # "small integer" opcodes
+ if data_len == 0 or data_len == 1 and data[0] == 0:
+ return bytes([opcodes.OP_0])
+ elif data_len == 1 and data[0] <= 16:
+ return bytes([opcodes.OP_1 - 1 + data[0]])
+ elif data_len == 1 and data[0] == 0x81:
+ return bytes([opcodes.OP_1NEGATE])
+
+ return _op_push(data_len) + data
+
+
+def make_op_return(x: bytes) -> bytes:
+ return bytes([opcodes.OP_RETURN]) + push_script(x)
+
+
+def add_number_to_script(i: int) -> bytes:
+ return push_script(script_num_to_bytes(i))
+
+
+def construct_witness(items: Sequence[Union[str, int, bytes]]) -> bytes:
+ """Constructs a witness from the given stack items."""
+ witness = bytearray()
+ witness += var_int(len(items))
+ for item in items:
+ if type(item) is int:
+ item = script_num_to_bytes(item)
+ elif isinstance(item, (bytes, bytearray)):
+ pass # use as-is
+ else:
+ assert is_hex_str(item), repr(item)
+ item = bfh(item)
+ witness += witness_push(item)
+ return bytes(witness)
+
+
+def construct_script(
+ items: Sequence[Union[str, int, bytes, opcodes, 'OPPushDataGeneric']],
+ *,
+ values: Optional[Mapping[int, Any]] = None, # can be used to substitute into OPPushDataGeneric
+) -> bytes:
+ """Constructs bitcoin script from given items."""
+ from .transaction import OPPushDataGeneric
+ script = bytearray()
+ values = values or {}
+ for i, item in enumerate(items):
+ if i in values:
+ assert OPPushDataGeneric.is_instance(item), f"tried to substitute into {item=!r}"
+ item = values[i]
+ if isinstance(item, opcodes):
+ script += bytes([item])
+ elif type(item) is int:
+ script += add_number_to_script(item)
+ elif isinstance(item, (bytes, bytearray)):
+ script += push_script(item)
+ elif isinstance(item, str):
+ assert is_hex_str(item)
+ script += push_script(bfh(item))
+ else:
+ raise Exception(f'unexpected item for script: {item!r} at idx={i}')
+ return bytes(script)
+
+
+def relayfee(network: 'Network' = None) -> int:
+ """Returns feerate in sat/kbyte."""
+ from .fee_policy import FEERATE_MIN_RELAY, FEERATE_DEFAULT_RELAY, FEERATE_MAX_RELAY
+ if network and network.relay_fee is not None:
+ fee = network.relay_fee
+ else:
+ fee = FEERATE_DEFAULT_RELAY
+ # sanity safeguards, as network.relay_fee is coming from a server:
+ fee = min(fee, FEERATE_MAX_RELAY)
+ fee = max(fee, FEERATE_MIN_RELAY)
+ return fee
+
+
+# see https://github.com/bitcoin/bitcoin/blob/a62f0ed64f8bbbdfe6467ac5ce92ef5b5222d1bd/src/policy/policy.cpp#L14
+# and https://github.com/lightningnetwork/lightning-rfc/blob/7e3dce42cbe4fa4592320db6a4e06c26bb99122b/03-transactions.md#dust-limits
+DUST_LIMIT_P2PKH = 546
+DUST_LIMIT_P2SH = 540
+DUST_LIMIT_UNKNOWN_SEGWIT = 354
+DUST_LIMIT_P2WSH = 330
+DUST_LIMIT_P2WPKH = 294
+
+
+def dust_threshold(network: 'Network' = None) -> int:
+ """Returns the dust limit in satoshis."""
+ return DUST_LIMIT_P2PKH
+
+
+def hash_encode(x: bytes) -> str:
+ return x[::-1].hex()
+
+
+def hash_decode(x: str) -> bytes:
+ return bfh(x)[::-1]
+
+
+############ functions from pywallet #####################
+
+def hash160_to_b58_address(h160: bytes, addrtype: int) -> str:
+ s = bytes([addrtype]) + h160
+ s = s + sha256d(s)[0:4]
+ return base_encode(s, base=58)
+
+
+def b58_address_to_hash160(addr: str) -> Tuple[int, bytes]:
+ addr = to_bytes(addr, 'ascii')
+ _bytes = DecodeBase58Check(addr)
+ if len(_bytes) != 21:
+ raise Exception(f'expected 21 payload bytes in base58 address. got: {len(_bytes)}')
+ return _bytes[0], _bytes[1:21]
+
+
+def hash160_to_p2pkh(h160: bytes, *, net=None) -> str:
+ if net is None: net = constants.net
+ return hash160_to_b58_address(h160, net.ADDRTYPE_P2PKH)
+
+
+def hash160_to_p2sh(h160: bytes, *, net=None) -> str:
+ if net is None: net = constants.net
+ return hash160_to_b58_address(h160, net.ADDRTYPE_P2SH)
+
+
+def public_key_to_p2pkh(public_key: bytes, *, net=None) -> str:
+ return hash160_to_p2pkh(hash_160(public_key), net=net)
+
+
+def hash_to_segwit_addr(h: bytes, witver: int, *, net=None) -> str:
+ if net is None: net = constants.net
+ addr = segwit_addr.encode_segwit_address(net.SEGWIT_HRP, witver, h)
+ assert addr is not None
+ return addr
+
+
+def public_key_to_p2wpkh(public_key: bytes, *, net=None) -> str:
+ return hash_to_segwit_addr(hash_160(public_key), witver=0, net=net)
+
+
+def script_to_p2wsh(script: bytes, *, net=None) -> str:
+ return hash_to_segwit_addr(sha256(script), witver=0, net=net)
+
+
+def p2wsh_nested_script(witness_script: bytes) -> bytes:
+ wsh = sha256(witness_script)
+ return construct_script([0, wsh])
+
+
+def pubkey_to_address(txin_type: str, pubkey: str, *, net=None) -> str:
+ from . import descriptor
+ desc = descriptor.get_singlesig_descriptor_from_legacy_leaf(pubkey=pubkey, script_type=txin_type)
+ return desc.expand().address(net=net)
+
+
+# TODO this method is confusingly named
+def redeem_script_to_address(txin_type: str, scriptcode: bytes, *, net=None) -> str:
+ assert isinstance(scriptcode, bytes)
+ if txin_type == 'p2sh':
+ # given scriptcode is a redeem_script
+ return hash160_to_p2sh(hash_160(scriptcode), net=net)
+ elif txin_type == 'p2wsh':
+ # given scriptcode is a witness_script
+ return script_to_p2wsh(scriptcode, net=net)
+ elif txin_type == 'p2wsh-p2sh':
+ # given scriptcode is a witness_script
+ redeem_script = p2wsh_nested_script(scriptcode)
+ return hash160_to_p2sh(hash_160(redeem_script), net=net)
+ else:
+ raise NotImplementedError(txin_type)
+
+
+def script_to_address(script: bytes, *, net=None) -> Optional[str]:
+ from .transaction import get_address_from_output_script
+ return get_address_from_output_script(script, net=net)
+
+
+def address_to_script(addr: str, *, net=None) -> bytes:
+ if net is None: net = constants.net
+ if not is_address(addr, net=net):
+ raise BitcoinException(f"invalid bitcoin address: {neuter_bitcoin_address(addr)}")
+ witver, witprog = segwit_addr.decode_segwit_address(net.SEGWIT_HRP, addr)
+ if witprog is not None:
+ if not (0 <= witver <= 16):
+ raise BitcoinException(f'impossible witness version: {witver}')
+ return construct_script([witver, bytes(witprog)])
+ addrtype, hash_160_ = b58_address_to_hash160(addr)
+ if addrtype == net.ADDRTYPE_P2PKH:
+ script = pubkeyhash_to_p2pkh_script(hash_160_)
+ elif addrtype == net.ADDRTYPE_P2SH:
+ script = construct_script([opcodes.OP_HASH160, hash_160_, opcodes.OP_EQUAL])
+ else:
+ raise BitcoinException(f'unknown address type: {addrtype}')
+ return script
+
+
+def neuter_bitcoin_address(addr: str) -> str:
+ """Truncate a bitcoin address, for display in errors that might get sent to the crash reporter,
+ to reduce harm to the user's privacy.
+ """
+ assert isinstance(addr, str), type(addr)
+ if len(addr) <= 7:
+ return addr
+ neutered_addr = addr[:5] + '..' + addr[-2:]
+ return f"{neutered_addr!r} (len={len(addr)})"
+
+
+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()
+ WITVER1_P2TR = enum.auto()
+
+
+def address_to_payload(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 bitcoin address: {neuter_bitcoin_address(addr)}")
+ witver, witprog = segwit_addr.decode_segwit_address(net.SEGWIT_HRP, addr)
+ if witprog is not None:
+ if witver == 0:
+ 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)}")
+ elif witver == 1:
+ if len(witprog) == 32:
+ return OnchainOutputType.WITVER1_P2TR, bytes(witprog)
+ else:
+ raise BitcoinException(f"unexpected length for segwit witver=1 witprog: len={len(witprog)}")
+ else:
+ raise BitcoinException(f"not implemented handling for witver={witver}")
+ 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, *, net=None) -> str:
+ script = address_to_script(addr, net=net)
+ return script_to_scripthash(script)
+
+
+def script_to_scripthash(script: bytes) -> str:
+ h = sha256(script)
+ return h[::-1].hex()
+
+
+def pubkeyhash_to_p2pkh_script(pubkey_hash160: bytes) -> bytes:
+ return construct_script([
+ opcodes.OP_DUP,
+ opcodes.OP_HASH160,
+ pubkey_hash160,
+ opcodes.OP_EQUALVERIFY,
+ opcodes.OP_CHECKSIG
+ ])
+
+
+__b58chars = b'123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
+assert len(__b58chars) == 58
+__b58chars_inv = inv_dict(dict(enumerate(__b58chars)))
+
+__b43chars = b'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ$*+-./:'
+assert len(__b43chars) == 43
+__b43chars_inv = inv_dict(dict(enumerate(__b43chars)))
+
+
+class BaseDecodeError(BitcoinException): pass
+
+
+def base_encode(v: bytes, *, base: int) -> str:
+ """ encode v, which is a string of bytes, to base58."""
+ assert_bytes(v)
+ if base not in (58, 43):
+ raise ValueError('not supported base: {}'.format(base))
+ chars = __b58chars
+ if base == 43:
+ chars = __b43chars
+
+ origlen = len(v)
+ v = v.lstrip(b'\x00')
+ newlen = len(v)
+
+ num = int.from_bytes(v, byteorder='big')
+ string = b""
+ while num:
+ num, idx = divmod(num, base)
+ string = chars[idx:idx + 1] + string
+
+ result = chars[0:1] * (origlen - newlen) + string
+ return result.decode('ascii')
+
+
+def base_decode(v: Union[bytes, str], *, base: int) -> Optional[bytes]:
+ """ decode v into a string of len bytes.
+
+ based on the work of David Keijser in https://github.com/keis/base58
+ """
+ # assert_bytes(v)
+ v = to_bytes(v, 'ascii')
+ if base not in (58, 43):
+ raise ValueError('not supported base: {}'.format(base))
+ chars = __b58chars
+ chars_inv = __b58chars_inv
+ if base == 43:
+ chars = __b43chars
+ chars_inv = __b43chars_inv
+
+ origlen = len(v)
+ v = v.lstrip(chars[0:1])
+ newlen = len(v)
+
+ num = 0
+ try:
+ for char in v:
+ num = num * base + chars_inv[char]
+ except KeyError:
+ raise BaseDecodeError('Forbidden character {} for base {}'.format(char, base))
+
+ return num.to_bytes(origlen - newlen + (num.bit_length() + 7) // 8, 'big')
+
+
+class InvalidChecksum(BaseDecodeError):
+ pass
+
+
+def EncodeBase58Check(vchIn: bytes) -> str:
+ hash = sha256d(vchIn)
+ return base_encode(vchIn + hash[0:4], base=58)
+
+
+def DecodeBase58Check(psz: Union[bytes, str]) -> bytes:
+ vchRet = base_decode(psz, base=58)
+ payload = vchRet[0:-4]
+ csum_found = vchRet[-4:]
+ csum_calculated = sha256d(payload)[0:4]
+ if csum_calculated != csum_found:
+ raise InvalidChecksum(f'calculated {csum_calculated.hex()}, found {csum_found.hex()}')
+ else:
+ return payload
+
+
+# backwards compat
+# extended WIF for segwit (used in 3.0.x; but still used internally)
+# the keys in this dict should be a superset of what Imported Wallets can import
+WIF_SCRIPT_TYPES = {
+ 'p2pkh': 0,
+ 'p2wpkh': 1,
+ 'p2wpkh-p2sh': 2,
+ 'p2sh': 5,
+ 'p2wsh': 6,
+ 'p2wsh-p2sh': 7
+}
+WIF_SCRIPT_TYPES_INV = inv_dict(WIF_SCRIPT_TYPES)
+
+
+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:
+ # we only export secrets inside curve range
+ secret = ecc.ECPrivkey.normalize_secret_bytes(secret)
+ if internal_use:
+ prefix = bytes([(WIF_SCRIPT_TYPES[txin_type] + constants.net.WIF_PREFIX) & 255])
+ else:
+ prefix = bytes([constants.net.WIF_PREFIX])
+ suffix = b'\01' if compressed else b''
+ vchIn = prefix + secret + suffix
+ base58_wif = EncodeBase58Check(vchIn)
+ if internal_use:
+ return base58_wif
+ else:
+ return '{}:{}'.format(txin_type, base58_wif)
+
+
+def deserialize_privkey(key: str) -> Tuple[str, bytes, bool]:
+ if is_minikey(key):
+ return 'p2pkh', minikey_to_private_key(key), False
+
+ txin_type = None
+ if ':' in key:
+ txin_type, key = key.split(sep=':', maxsplit=1)
+ if txin_type not in WIF_SCRIPT_TYPES:
+ raise BitcoinException('unknown script type: {}'.format(txin_type))
+ try:
+ vch = DecodeBase58Check(key)
+ except Exception as e:
+ neutered_privkey = str(key)[:3] + '..' + str(key)[-2:]
+ raise BaseDecodeError(f"cannot deserialize privkey {neutered_privkey}") from e
+
+ if txin_type is None:
+ # keys exported in version 3.0.x encoded script type in first byte
+ prefix_value = vch[0] - constants.net.WIF_PREFIX
+ try:
+ txin_type = WIF_SCRIPT_TYPES_INV[prefix_value]
+ except KeyError as e:
+ raise BitcoinException('invalid prefix ({}) for WIF key (1)'.format(vch[0])) from None
+ else:
+ # all other keys must have a fixed first byte
+ if vch[0] != constants.net.WIF_PREFIX:
+ raise BitcoinException('invalid prefix ({}) for WIF key (2)'.format(vch[0]))
+
+ if len(vch) not in [33, 34]:
+ raise BitcoinException('invalid vch len for WIF key: {}'.format(len(vch)))
+ compressed = False
+ if len(vch) == 34:
+ if vch[33] == 0x01:
+ compressed = True
+ else:
+ raise BitcoinException(f'invalid WIF key. length suggests compressed pubkey, '
+ f'but last byte is {vch[33]} != 0x01')
+
+ if is_segwit_script_type(txin_type) and not compressed:
+ raise BitcoinException('only compressed public keys can be used in segwit scripts')
+
+ secret_bytes = vch[1:33]
+ # we accept secrets outside curve range; cast into range here:
+ secret_bytes = ecc.ECPrivkey.normalize_secret_bytes(secret_bytes)
+ return txin_type, secret_bytes, compressed
+
+
+def is_compressed_privkey(sec: str) -> bool:
+ return deserialize_privkey(sec)[2]
+
+
+def address_from_private_key(sec: str) -> str:
+ txin_type, privkey, compressed = deserialize_privkey(sec)
+ public_key = ecc.ECPrivkey(privkey).get_public_key_hex(compressed=compressed)
+ return pubkey_to_address(txin_type, public_key)
+
+
+def is_segwit_address(addr: str, *, net=None) -> bool:
+ if net is None: net = constants.net
+ try:
+ witver, witprog = segwit_addr.decode_segwit_address(net.SEGWIT_HRP, addr)
+ except Exception as e:
+ return False
+ return witprog is not None
+
+
+def is_taproot_address(addr: str, *, net=None) -> bool:
+ if net is None: net = constants.net
+ try:
+ witver, witprog = segwit_addr.decode_segwit_address(net.SEGWIT_HRP, addr)
+ except Exception as e:
+ return False
+ return witver == 1
+
+
+def is_b58_address(addr: str, *, net=None) -> bool:
+ if net is None: net = constants.net
+ try:
+ # test length, checksum, encoding:
+ addrtype, h = b58_address_to_hash160(addr)
+ except Exception as e:
+ return False
+ if addrtype not in [net.ADDRTYPE_P2PKH, net.ADDRTYPE_P2SH]:
+ return False
+ return True
+
+
+def is_address(addr: str, *, net=None) -> bool:
+ return is_segwit_address(addr, net=net) \
+ or is_b58_address(addr, net=net)
+
+
+def is_private_key(key: str, *, raise_on_error=False) -> bool:
+ try:
+ deserialize_privkey(key)
+ return True
+ except BaseException as e:
+ if raise_on_error:
+ raise
+ return False
+
+
+########### end pywallet functions #######################
+
+def is_minikey(text: str) -> bool:
+ # Minikeys are typically 22 or 30 characters, but this routine
+ # permits any length of 20 or more provided the minikey is valid.
+ # A valid minikey must begin with an 'S', be in base58, and when
+ # suffixed with '?' have its SHA256 hash begin with a zero byte.
+ # They are widely used in Casascius physical bitcoins.
+ return (len(text) >= 20 and text[0] == 'S'
+ and all(ord(c) in __b58chars for c in text)
+ and sha256(text + '?')[0] == 0x00)
+
+
+def minikey_to_private_key(text: str) -> bytes:
+ return sha256(text)
+
+
+def _get_dummy_address(purpose: str) -> str:
+ return redeem_script_to_address('p2wsh', sha256(bytes(purpose, "utf8")))
+
+
+_dummy_addr_funcs = set()
+
+
+class DummyAddress:
+ """dummy address for fee estimation of funding tx
+ Use e.g. as: DummyAddress.CHANNEL
+ """
+ def purpose(func):
+ _dummy_addr_funcs.add(func)
+ return classproperty(func)
+
+ @purpose
+ def CHANNEL(self) -> str:
+ return _get_dummy_address("channel")
+ @purpose
+ def SWAP(self) -> str:
+ return _get_dummy_address("swap")
+
+ @classmethod
+ def is_dummy_address(cls, addr: str) -> bool:
+ return addr in (f(cls) for f in _dummy_addr_funcs)
+
+
+class DummyAddressUsedInTxException(Exception): pass
+
+
+def taproot_tweak_pubkey(pubkey32: bytes, h: bytes) -> Tuple[int, bytes]:
+ assert isinstance(pubkey32, bytes), type(pubkey32)
+ assert isinstance(h, bytes), type(h)
+ assert len(pubkey32) == 32, len(pubkey32)
+ int_from_bytes = lambda x: int.from_bytes(x, byteorder="big", signed=False)
+
+ tweak = int_from_bytes(bip340_tagged_hash(b"TapTweak", pubkey32 + h))
+ if tweak >= ecc.CURVE_ORDER:
+ raise ValueError
+ P = ecc.ECPubkey(b"\x02" + pubkey32)
+ Q = P + (ecc.GENERATOR * tweak)
+ return 0 if Q.has_even_y() else 1, Q.get_public_key_bytes(compressed=True)[1:]
+
+
+def taproot_tweak_seckey(seckey0: bytes, h: bytes) -> bytes:
+ assert isinstance(seckey0, bytes), type(seckey0)
+ assert isinstance(h, bytes), type(h)
+ assert len(seckey0) == 32, len(seckey0)
+ int_from_bytes = lambda x: int.from_bytes(x, byteorder="big", signed=False)
+
+ P = ecc.ECPrivkey(seckey0)
+ seckey = P.secret_scalar if P.has_even_y() else ecc.CURVE_ORDER - P.secret_scalar
+ pubkey32 = P.get_public_key_bytes(compressed=True)[1:]
+ tweak = int_from_bytes(bip340_tagged_hash(b"TapTweak", pubkey32 + h))
+ if tweak >= ecc.CURVE_ORDER:
+ raise ValueError
+ return int.to_bytes((seckey + tweak) % ecc.CURVE_ORDER, length=32, byteorder="big", signed=False)
+
+
+# a TapTree is either:
+# - a (leaf_version, script) tuple (leaf_version is 0xc0 for BIP-0342 scripts)
+# - a list of two elements, each with the same structure as TapTree itself
+TapTreeLeaf = Tuple[int, bytes]
+TapTree = Union[TapTreeLeaf, Sequence['TapTree']]
+
+
+def taproot_tree_helper(script_tree: TapTree):
+ if isinstance(script_tree, tuple):
+ leaf_version, script = script_tree
+ h = bip340_tagged_hash(b"TapLeaf", bytes([leaf_version]) + witness_push(script))
+ return [((leaf_version, script), bytes())], h
+ left, left_h = taproot_tree_helper(script_tree[0])
+ right, right_h = taproot_tree_helper(script_tree[1])
+ ret = [(l, c + right_h) for l, c in left] + [(l, c + left_h) for l, c in right]
+ if right_h < left_h:
+ left_h, right_h = right_h, left_h
+ return ret, bip340_tagged_hash(b"TapBranch", left_h + right_h)
+
+
+def taproot_output_script(internal_pubkey: bytes, *, script_tree: Optional[TapTree]) -> bytes:
+ """Given an internal public key and a tree of scripts, compute the output script."""
+ assert isinstance(internal_pubkey, bytes), type(internal_pubkey)
+ assert len(internal_pubkey) == 32, len(internal_pubkey)
+ if script_tree is None:
+ merkle_root = bytes()
+ else:
+ _, merkle_root = taproot_tree_helper(script_tree)
+ _, output_pubkey = taproot_tweak_pubkey(internal_pubkey, merkle_root)
+ return construct_script([1, output_pubkey])
+
+
+def control_block_for_taproot_script_spend(
+ *, internal_pubkey: bytes, script_tree: TapTree, script_num: int,
+) -> Tuple[bytes, bytes]:
+ """Constructs the control block necessary for spending a taproot UTXO using a script.
+ script_num indicates which script to use, which indexes into (flattened) script_tree.
+ """
+ assert isinstance(internal_pubkey, bytes), type(internal_pubkey)
+ assert len(internal_pubkey) == 32, len(internal_pubkey)
+ info, merkle_root = taproot_tree_helper(script_tree)
+ (leaf_version, leaf_script), merkle_path = info[script_num]
+ output_pubkey_y_parity, _ = taproot_tweak_pubkey(internal_pubkey, merkle_root)
+ pubkey_data = bytes([output_pubkey_y_parity + leaf_version]) + internal_pubkey
+ control_block = pubkey_data + merkle_path
+ return (leaf_script, control_block)
+
+
+# user message signing
+def usermessage_magic(message: bytes) -> bytes:
+ length = var_int(len(message))
+ return b"\x18Bitcoin Signed Message:\n" + length + message
+
+
+def ecdsa_sign_usermessage(ec_privkey, message: Union[bytes, str], *, is_compressed: bool) -> bytes:
+ message = to_bytes(message, 'utf8')
+ msg32 = sha256d(usermessage_magic(message))
+ return ec_privkey.ecdsa_sign_recoverable(msg32, is_compressed=is_compressed)
+
+
+def verify_usermessage_with_address(address: str, sig65: bytes, message: bytes, *, net=None) -> bool:
+ from electrum_ecc import ECPubkey
+ assert_bytes(sig65, message)
+ if net is None: net = constants.net
+ h = sha256d(usermessage_magic(message))
+ try:
+ public_key, compressed, txin_type_guess = ECPubkey.from_ecdsa_sig65(sig65, h)
+ except Exception as e:
+ return False
+ # check public key using the address
+ pubkey_hex = public_key.get_public_key_hex(compressed)
+ txin_types = (txin_type_guess,) if txin_type_guess else ('p2pkh', 'p2wpkh', 'p2wpkh-p2sh')
+ for txin_type in txin_types:
+ addr = pubkey_to_address(txin_type, pubkey_hex, net=net)
+ if address == addr:
+ break
+ else:
+ return False
+ # check message
+ # note: `$ bitcoin-cli verifymessage` does NOT enforce the low-S rule for ecdsa sigs
+ return public_key.ecdsa_verify(sig65[1:], h, enforce_low_s=False)
diff --git a/electrum/blockchain.py b/electrum/blockchain.py
new file mode 100644
index 000000000000..5d101a796870
--- /dev/null
+++ b/electrum/blockchain.py
@@ -0,0 +1,702 @@
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2012 thomasv@ecdsa.org
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+import os
+import threading
+import time
+from typing import Optional, Dict, Mapping, Sequence, TYPE_CHECKING
+
+from . import util
+from .bitcoin import hash_encode
+from .crypto import sha256d
+from . import constants
+from .util import bfh, with_lock
+from .logging import get_logger, Logger
+
+if TYPE_CHECKING:
+ from .simple_config import SimpleConfig
+
+_logger = get_logger(__name__)
+
+HEADER_SIZE = 80 # bytes
+CHUNK_SIZE = 2016 # num headers in a difficulty retarget period
+
+# see https://github.com/bitcoin/bitcoin/blob/feedb9c84e72e4fff489810a2bbeec09bcda5763/src/chainparams.cpp#L76
+MAX_TARGET = 0x00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff # compact: 0x1d00ffff
+
+
+class MissingHeader(Exception):
+ pass
+
+
+class InvalidHeader(Exception):
+ pass
+
+
+def serialize_header(header_dict: dict) -> bytes:
+ s = (
+ int.to_bytes(header_dict['version'], length=4, byteorder="little", signed=False)
+ + bfh(header_dict['prev_block_hash'])[::-1]
+ + bfh(header_dict['merkle_root'])[::-1]
+ + int.to_bytes(int(header_dict['timestamp']), length=4, byteorder="little", signed=False)
+ + int.to_bytes(int(header_dict['bits']), length=4, byteorder="little", signed=False)
+ + int.to_bytes(int(header_dict['nonce']), length=4, byteorder="little", signed=False))
+ return s
+
+
+def deserialize_header(s: bytes, height: int) -> dict:
+ if not s:
+ raise InvalidHeader('Invalid header: {}'.format(s))
+ if len(s) != HEADER_SIZE:
+ raise InvalidHeader('Invalid header length: {}'.format(len(s)))
+ h = {}
+ h['version'] = int.from_bytes(s[0:4], byteorder='little')
+ h['prev_block_hash'] = hash_encode(s[4:36])
+ h['merkle_root'] = hash_encode(s[36:68])
+ h['timestamp'] = int.from_bytes(s[68:72], byteorder='little')
+ h['bits'] = int.from_bytes(s[72:76], byteorder='little')
+ h['nonce'] = int.from_bytes(s[76:80], byteorder='little')
+ h['block_height'] = height
+ return h
+
+
+def hash_header(header: dict) -> str:
+ if header is None:
+ return '0' * 64
+ if header.get('prev_block_hash') is None:
+ header['prev_block_hash'] = '00'*32
+ return hash_raw_header(serialize_header(header))
+
+
+def hash_raw_header(header: bytes) -> str:
+ assert isinstance(header, bytes)
+ return hash_encode(sha256d(header))
+
+
+pow_hash_header = hash_header
+
+
+# key: blockhash hex at forkpoint
+# the chain at some key is the best chain that includes the given hash
+blockchains = {} # type: Dict[str, Blockchain]
+blockchains_lock = threading.RLock() # lock order: take this last; so after Blockchain.lock
+
+
+def read_blockchains(config: 'SimpleConfig'):
+ best_chain = Blockchain(config=config,
+ forkpoint=0,
+ parent=None,
+ forkpoint_hash=constants.net.GENESIS,
+ prev_hash=None)
+ blockchains[constants.net.GENESIS] = best_chain
+ # consistency checks
+ if best_chain.height() > constants.net.max_checkpoint():
+ header_after_cp = best_chain.read_header(constants.net.max_checkpoint()+1)
+ if not header_after_cp or not best_chain.can_connect(header_after_cp, check_height=False):
+ _logger.info("[blockchain] deleting best chain. cannot connect header after last cp to last cp.")
+ os.unlink(best_chain.path())
+ best_chain.update_size()
+ # forks
+ fdir = os.path.join(util.get_headers_dir(config), 'forks')
+ util.make_dir(fdir)
+ # files are named as: fork2_{forkpoint}_{prev_hash}_{first_hash}
+ l = filter(lambda x: x.startswith('fork2_') and '.' not in x, os.listdir(fdir))
+ l = sorted(l, key=lambda x: int(x.split('_')[1])) # sort by forkpoint
+
+ def delete_chain(filename, reason):
+ _logger.info(f"[blockchain] deleting chain {filename}: {reason}")
+ os.unlink(os.path.join(fdir, filename))
+
+ def instantiate_chain(filename):
+ __, forkpoint, prev_hash, first_hash = filename.split('_')
+ forkpoint = int(forkpoint)
+ prev_hash = (64-len(prev_hash)) * "0" + prev_hash # left-pad with zeroes
+ first_hash = (64-len(first_hash)) * "0" + first_hash
+ # forks below the max checkpoint are not allowed
+ if forkpoint <= constants.net.max_checkpoint():
+ delete_chain(filename, "deleting fork below max checkpoint")
+ return
+ # find parent (sorting by forkpoint guarantees it's already instantiated)
+ for parent in blockchains.values():
+ if parent.check_hash(forkpoint - 1, prev_hash):
+ break
+ else:
+ delete_chain(filename, "cannot find parent for chain")
+ return
+ b = Blockchain(config=config,
+ forkpoint=forkpoint,
+ parent=parent,
+ forkpoint_hash=first_hash,
+ prev_hash=prev_hash)
+ # consistency checks
+ h = b.read_header(b.forkpoint)
+ if first_hash != hash_header(h):
+ delete_chain(filename, "incorrect first hash for chain")
+ return
+ if not b.parent.can_connect(h, check_height=False):
+ delete_chain(filename, "cannot connect chain to parent")
+ return
+ chain_id = b.get_id()
+ assert first_hash == chain_id, (first_hash, chain_id)
+ blockchains[chain_id] = b
+
+ for filename in l:
+ instantiate_chain(filename)
+
+
+def get_best_chain() -> 'Blockchain':
+ return blockchains[constants.net.GENESIS]
+
+
+# block hash -> chain work; up to and including that block
+_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) * CHUNK_SIZE
+ if not os.path.exists(filename) or os.path.getsize(filename) < length:
+ with open(filename, 'wb') as f:
+ if length > 0:
+ f.seek(length - 1)
+ f.write(b'\x00')
+ util.ensure_sparse_file(filename)
+ with b.lock:
+ b.update_size()
+
+
+class Blockchain(Logger):
+ """
+ Manages blockchain headers and their verification
+ """
+
+ def __init__(self, config: 'SimpleConfig', forkpoint: int, parent: Optional['Blockchain'],
+ forkpoint_hash: str, prev_hash: Optional[str]):
+ assert isinstance(forkpoint_hash, str) and len(forkpoint_hash) == 64, forkpoint_hash
+ assert (prev_hash is None) or (isinstance(prev_hash, str) and len(prev_hash) == 64), prev_hash
+ # assert (parent is None) == (forkpoint == 0)
+ if 0 < forkpoint <= constants.net.max_checkpoint():
+ raise Exception(f"cannot fork below max checkpoint. forkpoint: {forkpoint}")
+ Logger.__init__(self)
+ self.config = config
+ self.forkpoint = forkpoint # height of first header
+ self.parent = parent
+ self._forkpoint_hash = forkpoint_hash # blockhash at forkpoint. "first hash"
+ self._prev_hash = prev_hash # blockhash immediately before forkpoint
+ self.lock = threading.RLock()
+ self.update_size()
+
+ @property
+ def checkpoints(self):
+ return constants.net.CHECKPOINTS
+
+ def get_max_child(self) -> Optional[int]:
+ children = self.get_direct_children()
+ return max([x.forkpoint for x in children]) if children else None
+
+ def get_max_forkpoint(self) -> int:
+ """Returns the max height where there is a fork
+ related to this chain.
+ """
+ mc = self.get_max_child()
+ return mc if mc is not None else self.forkpoint
+
+ def get_direct_children(self) -> Sequence['Blockchain']:
+ with blockchains_lock:
+ return list(filter(lambda y: y.parent==self, blockchains.values()))
+
+ def get_parent_heights(self) -> Mapping['Blockchain', int]:
+ """Returns map: (parent chain -> height of last common block)"""
+ with self.lock, blockchains_lock:
+ result = {self: self.height()}
+ chain = self
+ while True:
+ parent = chain.parent
+ if parent is None: break
+ result[parent] = chain.forkpoint - 1
+ chain = parent
+ return result
+
+ def get_height_of_last_common_block_with_chain(self, other_chain: 'Blockchain') -> int:
+ last_common_block_height = 0
+ our_parents = self.get_parent_heights()
+ their_parents = other_chain.get_parent_heights()
+ for chain in our_parents:
+ if chain in their_parents:
+ h = min(our_parents[chain], their_parents[chain])
+ last_common_block_height = max(last_common_block_height, h)
+ return last_common_block_height
+
+ @with_lock
+ def get_branch_size(self) -> int:
+ return self.height() - self.get_max_forkpoint() + 1
+
+ def get_name(self) -> str:
+ return self.get_hash(self.get_max_forkpoint()).lstrip('0')[0:10]
+
+ def check_header(self, header: dict) -> bool:
+ header_hash = hash_header(header)
+ height = header.get('block_height')
+ return self.check_hash(height, header_hash)
+
+ def check_hash(self, height: int, header_hash: str) -> bool:
+ """Returns whether the hash of the block at given height
+ is the given hash.
+ """
+ assert isinstance(header_hash, str) and len(header_hash) == 64, header_hash # hex
+ try:
+ return header_hash == self.get_hash(height)
+ except Exception:
+ return False
+
+ def fork(parent, header: dict) -> 'Blockchain':
+ if not parent.can_connect(header, check_height=False):
+ raise Exception("forking header does not connect to parent chain")
+ forkpoint = header.get('block_height')
+ self = Blockchain(config=parent.config,
+ forkpoint=forkpoint,
+ parent=parent,
+ forkpoint_hash=hash_header(header),
+ prev_hash=parent.get_hash(forkpoint-1))
+ self.assert_headers_file_available(parent.path())
+ open(self.path(), 'w+').close()
+ self.save_header(header)
+ # put into global dict. note that in some cases
+ # save_header might have already put it there but that's OK
+ chain_id = self.get_id()
+ with blockchains_lock:
+ blockchains[chain_id] = self
+ return self
+
+ @with_lock
+ def height(self) -> int:
+ return self.forkpoint + self.size() - 1
+
+ @with_lock
+ def size(self) -> int:
+ return self._size
+
+ @with_lock
+ def update_size(self) -> None:
+ p = self.path()
+ self._size = os.path.getsize(p)//HEADER_SIZE if os.path.exists(p) else 0
+
+ @classmethod
+ def verify_header(cls, header: dict, prev_hash: str, target: int, expected_header_hash: str=None) -> None:
+ _hash = hash_header(header)
+ if expected_header_hash and expected_header_hash != _hash:
+ raise InvalidHeader("hash mismatches with expected: {} vs {}".format(expected_header_hash, _hash))
+ if prev_hash != header.get('prev_block_hash'):
+ raise InvalidHeader("prev hash mismatch: %s vs %s" % (prev_hash, header.get('prev_block_hash')))
+ if constants.net.TESTNET:
+ return
+ bits = cls.target_to_bits(target)
+ if bits != header.get('bits'):
+ raise InvalidHeader("bits mismatch: %s vs %s" % (bits, header.get('bits')))
+ _pow_hash = pow_hash_header(header)
+ pow_hash_as_num = int.from_bytes(bfh(_pow_hash), byteorder='big')
+ if pow_hash_as_num > target:
+ raise InvalidHeader(f"insufficient proof of work: {pow_hash_as_num} vs target {target}")
+
+ def verify_chunk(self, index: int, data: bytes) -> None:
+ num = len(data) // HEADER_SIZE
+ start_height = index * CHUNK_SIZE
+ prev_hash = self.get_hash(start_height - 1)
+ target = self.get_target(index-1)
+ for i in range(num):
+ height = start_height + i
+ try:
+ expected_header_hash = self.get_hash(height)
+ except MissingHeader:
+ expected_header_hash = None
+ raw_header = data[i*HEADER_SIZE : (i+1)*HEADER_SIZE]
+ header = deserialize_header(raw_header, index*CHUNK_SIZE + i)
+ self.verify_header(header, prev_hash, target, expected_header_hash)
+ prev_hash = hash_header(header)
+
+ @with_lock
+ def path(self):
+ d = util.get_headers_dir(self.config)
+ if self.parent is None:
+ filename = 'blockchain_headers'
+ else:
+ assert self.forkpoint > 0, self.forkpoint
+ prev_hash = self._prev_hash.lstrip('0')
+ first_hash = self._forkpoint_hash.lstrip('0')
+ basename = f'fork2_{self.forkpoint}_{prev_hash}_{first_hash}'
+ filename = os.path.join('forks', basename)
+ return os.path.join(d, filename)
+
+ @with_lock
+ def save_chunk(self, index: int, chunk: bytes):
+ assert index >= 0, index
+ chunk_within_checkpoint_region = index < len(self.checkpoints)
+ # chunks in checkpoint region are the responsibility of the 'main chain'
+ if chunk_within_checkpoint_region and self.parent is not None:
+ main_chain = get_best_chain()
+ main_chain.save_chunk(index, chunk)
+ return
+
+ delta_height = (index * CHUNK_SIZE - self.forkpoint)
+ delta_bytes = delta_height * HEADER_SIZE
+ # if this chunk contains our forkpoint, only save the part after forkpoint
+ # (the part before is the responsibility of the parent)
+ if delta_bytes < 0:
+ chunk = chunk[-delta_bytes:]
+ delta_bytes = 0
+ truncate = not chunk_within_checkpoint_region
+ self.write(chunk, delta_bytes, truncate)
+ self.swap_with_parent()
+
+ def swap_with_parent(self) -> None:
+ with self.lock, blockchains_lock:
+ # do the swap; possibly multiple ones
+ cnt = 0
+ while True:
+ old_parent = self.parent
+ if not self._swap_with_parent():
+ break
+ # make sure we are making progress
+ cnt += 1
+ if cnt > len(blockchains):
+ raise Exception(f'swapping fork with parent too many times: {cnt}')
+ # we might have become the parent of some of our former siblings
+ for old_sibling in old_parent.get_direct_children():
+ if self.check_hash(old_sibling.forkpoint - 1, old_sibling._prev_hash):
+ old_sibling.parent = self
+
+ def _swap_with_parent(self) -> bool:
+ """Check if this chain became stronger than its parent, and swap
+ the underlying files if so. The Blockchain instances will keep
+ 'containing' the same headers, but their ids change and so
+ they will be stored in different files."""
+ if self.parent is None:
+ return False
+ if self.parent.get_chainwork() >= self.get_chainwork():
+ return False
+ self.logger.info(f"swapping {self.forkpoint} {self.parent.forkpoint}")
+ parent_branch_size = self.parent.height() - self.forkpoint + 1
+ forkpoint = self.forkpoint # type: Optional[int]
+ parent = self.parent # type: Optional[Blockchain]
+ child_old_id = self.get_id()
+ parent_old_id = parent.get_id()
+ # swap files
+ # child takes parent's name
+ # parent's new name will be something new (not child's old name)
+ self.assert_headers_file_available(self.path())
+ child_old_name = self.path()
+ with open(self.path(), 'rb') as f:
+ my_data = f.read()
+ self.assert_headers_file_available(parent.path())
+ assert forkpoint > parent.forkpoint, (f"forkpoint of parent chain ({parent.forkpoint}) "
+ f"should be at lower height than children's ({forkpoint})")
+ with open(parent.path(), 'rb') as f:
+ f.seek((forkpoint - parent.forkpoint)*HEADER_SIZE)
+ parent_data = f.read(parent_branch_size*HEADER_SIZE)
+ self.write(parent_data, 0)
+ parent.write(my_data, (forkpoint - parent.forkpoint)*HEADER_SIZE)
+ # swap parameters
+ self.parent, parent.parent = parent.parent, self # type: Optional[Blockchain], Optional[Blockchain]
+ self.forkpoint, parent.forkpoint = parent.forkpoint, self.forkpoint
+ self._forkpoint_hash, parent._forkpoint_hash = parent._forkpoint_hash, hash_raw_header(parent_data[:HEADER_SIZE])
+ self._prev_hash, parent._prev_hash = parent._prev_hash, self._prev_hash
+ # parent's new name
+ os.replace(child_old_name, parent.path())
+ self.update_size()
+ parent.update_size()
+ # update pointers
+ blockchains.pop(child_old_id, None)
+ blockchains.pop(parent_old_id, None)
+ blockchains[self.get_id()] = self
+ blockchains[parent.get_id()] = parent
+ return True
+
+ def get_id(self) -> str:
+ return self._forkpoint_hash
+
+ def assert_headers_file_available(self, path):
+ if os.path.exists(path):
+ return
+ elif not os.path.exists(util.get_headers_dir(self.config)):
+ raise FileNotFoundError('Electrum headers_dir does not exist. Was it deleted while running?')
+ else:
+ raise FileNotFoundError('Cannot find headers file but headers_dir is there. Should be at {}'.format(path))
+
+ @with_lock
+ def write(self, data: bytes, offset: int, truncate: bool = True, *, fsync: bool = True) -> None:
+ filename = self.path()
+ self.assert_headers_file_available(filename)
+ with open(filename, 'rb+') as f:
+ if truncate and offset != self._size * HEADER_SIZE:
+ f.seek(offset)
+ f.truncate()
+ f.seek(offset)
+ f.write(data)
+ if fsync:
+ f.flush()
+ os.fsync(f.fileno())
+ self.update_size()
+
+ @with_lock
+ def save_header(self, header: dict) -> None:
+ delta = header.get('block_height') - self.forkpoint
+ data = serialize_header(header)
+ # headers are only _appended_ to the end:
+ assert delta == self.size(), (delta, self.size())
+ assert len(data) == HEADER_SIZE
+ # note: we don't fsync, to improve perf. losing headers at end of file is ok.
+ self.write(data, delta*HEADER_SIZE, fsync=False)
+ self.swap_with_parent()
+
+ @with_lock
+ def read_header(self, height: int) -> Optional[dict]:
+ if height < 0:
+ return
+ if height < self.forkpoint:
+ return self.parent.read_header(height)
+ if height > self.height():
+ return
+ delta = height - self.forkpoint
+ name = self.path()
+ self.assert_headers_file_available(name)
+ with open(name, 'rb') as f:
+ f.seek(delta * HEADER_SIZE)
+ h = f.read(HEADER_SIZE)
+ if len(h) < HEADER_SIZE:
+ raise Exception('Expected to read a full header. This was only {} bytes'.format(len(h)))
+ if h == bytes([0])*HEADER_SIZE:
+ return None
+ return deserialize_header(h, height)
+
+ def header_at_tip(self) -> Optional[dict]:
+ """Return latest header."""
+ 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()
+ at_chunk_boundary = (height+1) % CHUNK_SIZE == 0
+ return within_cp_range and at_chunk_boundary
+
+ if height == -1:
+ return '0000000000000000000000000000000000000000000000000000000000000000'
+ elif height == 0:
+ return constants.net.GENESIS
+ elif is_height_checkpoint():
+ index = height // CHUNK_SIZE
+ h, t = self.checkpoints[index]
+ return h
+ else:
+ header = self.read_header(height)
+ if header is None:
+ raise MissingHeader(height)
+ return hash_header(header)
+
+ def get_target(self, index: int) -> int:
+ # compute target from chunk x, used in chunk x+1
+ if constants.net.TESTNET:
+ return 0
+ if index == -1:
+ return MAX_TARGET
+ if index < len(self.checkpoints):
+ h, t = self.checkpoints[index]
+ return t
+ # new target
+ first = self.read_header(index * CHUNK_SIZE)
+ last = self.read_header((index+1) * CHUNK_SIZE - 1)
+ if not first or not last:
+ raise MissingHeader()
+ bits = last.get('bits')
+ target = self.bits_to_target(bits)
+ nActualTimespan = last.get('timestamp') - first.get('timestamp')
+ nTargetTimespan = 14 * 24 * 60 * 60
+ nActualTimespan = max(nActualTimespan, nTargetTimespan // 4)
+ nActualTimespan = min(nActualTimespan, nTargetTimespan * 4)
+ new_target = min(MAX_TARGET, (target * nActualTimespan) // nTargetTimespan)
+ # not any target can be represented in 32 bits:
+ new_target = self.bits_to_target(self.target_to_bits(new_target))
+ return new_target
+
+ @classmethod
+ def bits_to_target(cls, bits: int) -> int:
+ # arith_uint256::SetCompact in Bitcoin Core
+ if not (0 <= bits < (1 << 32)):
+ raise InvalidHeader(f"bits should be uint32. got {bits!r}")
+ bitsN = (bits >> 24) & 0xff
+ bitsBase = bits & 0x7fffff
+ if bitsN <= 3:
+ target = bitsBase >> (8 * (3-bitsN))
+ else:
+ target = bitsBase << (8 * (bitsN-3))
+ if target != 0 and bits & 0x800000 != 0:
+ # Bit number 24 (0x800000) represents the sign of N
+ raise InvalidHeader("target cannot be negative")
+ if (target != 0 and
+ (bitsN > 34 or
+ (bitsN > 33 and bitsBase > 0xff) or
+ (bitsN > 32 and bitsBase > 0xffff))):
+ raise InvalidHeader("target has overflown")
+ return target
+
+ @classmethod
+ def target_to_bits(cls, target: int) -> int:
+ # arith_uint256::GetCompact in Bitcoin Core
+ # see https://github.com/bitcoin/bitcoin/blob/7fcf53f7b4524572d1d0c9a5fdc388e87eb02416/src/arith_uint256.cpp#L223
+ c = target.to_bytes(length=32, byteorder='big')
+ bitsN = len(c)
+ while bitsN > 0 and c[0] == 0:
+ c = c[1:]
+ bitsN -= 1
+ if len(c) < 3:
+ c += b'\x00'
+ bitsBase = int.from_bytes(c[:3], byteorder='big')
+ if bitsBase >= 0x800000:
+ bitsN += 1
+ bitsBase >>= 8
+ return bitsN << 24 | bitsBase
+
+ def chainwork_of_header_at_height(self, height: int) -> int:
+ """work done by single header at given height"""
+ chunk_idx = height // CHUNK_SIZE - 1
+ target = self.get_target(chunk_idx)
+ work = ((2 ** 256 - target - 1) // (target + 1)) + 1
+ return work
+
+ @with_lock
+ def get_chainwork(self, height=None) -> int:
+ if height is None:
+ height = max(0, self.height())
+ if constants.net.TESTNET:
+ # On testnet/regtest, difficulty works somewhat different.
+ # It's out of scope to properly implement that.
+ return height
+ last_retarget = height // CHUNK_SIZE * CHUNK_SIZE - 1
+ cached_height = last_retarget
+ while _CHAINWORK_CACHE.get(self.get_hash(cached_height)) is None:
+ if cached_height <= -1:
+ break
+ cached_height -= CHUNK_SIZE
+ assert cached_height >= -1, cached_height
+ running_total = _CHAINWORK_CACHE[self.get_hash(cached_height)]
+ while cached_height < last_retarget:
+ cached_height += CHUNK_SIZE
+ work_in_single_header = self.chainwork_of_header_at_height(cached_height)
+ work_in_chunk = CHUNK_SIZE * work_in_single_header
+ running_total += work_in_chunk
+ _CHAINWORK_CACHE[self.get_hash(cached_height)] = running_total
+ cached_height += CHUNK_SIZE
+ work_in_single_header = self.chainwork_of_header_at_height(cached_height)
+ work_in_last_partial_chunk = (height % CHUNK_SIZE + 1) * work_in_single_header
+ return running_total + work_in_last_partial_chunk
+
+ def can_connect(self, header: dict, *, check_height: bool = True) -> bool:
+ if header is None:
+ return False
+ height = header['block_height']
+ if check_height and self.height() != height - 1:
+ return False
+ if height == 0:
+ return hash_header(header) == constants.net.GENESIS
+ try:
+ prev_hash = self.get_hash(height - 1)
+ except Exception:
+ return False
+ if prev_hash != header.get('prev_block_hash'):
+ return False
+ try:
+ target = self.get_target(height // CHUNK_SIZE - 1)
+ except MissingHeader:
+ return False
+ try:
+ self.verify_header(header, prev_hash, target)
+ except BaseException as e:
+ return False
+ return True
+
+ def connect_chunk(self, idx: int, data: bytes) -> bool:
+ assert idx >= 0, idx
+ try:
+ self.verify_chunk(idx, data)
+ self.save_chunk(idx, data)
+ return True
+ except BaseException as e:
+ self.logger.info(f'verify_chunk idx {idx} failed: {repr(e)}')
+ return False
+
+ def get_checkpoints(self):
+ # for each chunk, store the hash of the last block and the target after the chunk
+ cp = []
+ n = self.height() // CHUNK_SIZE
+ for index in range(n):
+ h = self.get_hash((index+1) * CHUNK_SIZE -1)
+ target = self.get_target(index)
+ cp.append((h, target))
+ return cp
+
+
+def check_header(header: dict) -> Optional[Blockchain]:
+ """Returns any Blockchain that contains header, or None."""
+ if type(header) is not dict:
+ return None
+ with blockchains_lock: chains = list(blockchains.values())
+ for b in chains:
+ if b.check_header(header):
+ return b
+ return None
+
+
+def can_connect(header: dict) -> Optional[Blockchain]:
+ """Returns the Blockchain that has a tip that directly links up
+ with header, or None.
+ """
+ with blockchains_lock: chains = list(blockchains.values())
+ for b in chains:
+ if b.can_connect(header):
+ 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
diff --git a/electrum/bolt11.py b/electrum/bolt11.py
new file mode 100644
index 000000000000..ab3a357daf36
--- /dev/null
+++ b/electrum/bolt11.py
@@ -0,0 +1,594 @@
+#! /usr/bin/env python3
+# This was forked from https://github.com/rustyrussell/lightning-payencode/tree/acc16ec13a3fa1dc16c07af6ec67c261bd8aff23
+
+import io
+import re
+import time
+from hashlib import sha256
+from binascii import hexlify
+from decimal import Decimal
+from typing import Optional, TYPE_CHECKING, Type, Dict, Any, Sequence, Tuple
+import random
+
+import electrum_ecc as ecc
+
+from .bitcoin import hash160_to_b58_address, b58_address_to_hash160, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
+from .segwit_addr import bech32_encode, bech32_decode, CHARSET, CHARSET_INVERSE, convertbits
+from . import segwit_addr
+from . import constants
+from .constants import AbstractNet
+from .bitcoin import COIN
+
+if TYPE_CHECKING:
+ from .lnutil import LnFeatures
+
+
+class BOLT11InvoiceException(Exception): pass
+class BOLT11DecodeException(BOLT11InvoiceException): pass
+class BOLT11EncodeException(BOLT11InvoiceException): pass
+
+
+# BOLT #11:
+#
+# A writer MUST encode `amount` as a positive decimal integer with no
+# leading zeroes, SHOULD use the shortest representation possible.
+def shorten_amount(amount):
+ """ Given an amount in bitcoin, shorten it
+ """
+ # Convert to pico initially
+ amount = int(amount * 10**12)
+ units = ['p', 'n', 'u', 'm']
+ for unit in units:
+ if amount % 1000 == 0:
+ amount //= 1000
+ else:
+ break
+ else:
+ unit = ''
+ return str(amount) + unit
+
+def unshorten_amount(amount) -> Decimal:
+ """ Given a shortened amount, convert it into a decimal
+ """
+ # BOLT #11:
+ # The following `multiplier` letters are defined:
+ #
+ #* `m` (milli): multiply by 0.001
+ #* `u` (micro): multiply by 0.000001
+ #* `n` (nano): multiply by 0.000000001
+ #* `p` (pico): multiply by 0.000000000001
+ units = {
+ 'p': 10**12,
+ 'n': 10**9,
+ 'u': 10**6,
+ 'm': 10**3,
+ }
+ unit = str(amount)[-1]
+ # BOLT #11:
+ # A reader SHOULD fail if `amount` contains a non-digit, or is followed by
+ # anything except a `multiplier` in the table above.
+ if not re.fullmatch("\\d+[pnum]?", str(amount)):
+ raise BOLT11DecodeException("Invalid amount '{}'".format(amount))
+
+ if unit in units.keys():
+ return Decimal(amount[:-1]) / units[unit]
+ else:
+ return Decimal(amount)
+
+
+def encode_fallback_addr(fallback: str, net: Type[AbstractNet]) -> Sequence[int]:
+ """Encode all supported fallback addresses."""
+ wver, wprog_ints = segwit_addr.decode_segwit_address(net.SEGWIT_HRP, fallback)
+ if wver is not None:
+ wprog = bytes(wprog_ints)
+ else:
+ addrtype, addr = b58_address_to_hash160(fallback)
+ if addrtype == net.ADDRTYPE_P2PKH:
+ wver = 17
+ elif addrtype == net.ADDRTYPE_P2SH:
+ wver = 18
+ else:
+ raise BOLT11EncodeException(f"Unknown address type {addrtype} for {net}")
+ wprog = addr
+ data5 = convertbits(wprog, 8, 5)
+ assert data5 is not None
+ return tagged5('f', [wver] + list(data5))
+
+
+def parse_fallback_addr(data5: Sequence[int], net: Type[AbstractNet]) -> Optional[str]:
+ wver = data5[0]
+ data8 = bytes(convertbits(data5[1:], 5, 8, False))
+ if wver == 17:
+ addr = hash160_to_b58_address(data8, net.ADDRTYPE_P2PKH)
+ elif wver == 18:
+ addr = hash160_to_b58_address(data8, net.ADDRTYPE_P2SH)
+ elif wver <= 16:
+ addr = segwit_addr.encode_segwit_address(net.SEGWIT_HRP, wver, data8)
+ else:
+ return None
+ return addr
+
+
+def tagged5(char: str, data5: Sequence[int]) -> Sequence[int]:
+ assert len(data5) < (1 << 10)
+ return [CHARSET_INVERSE[char], len(data5) >> 5, len(data5) & 31] + data5
+
+
+def tagged8(char: str, data8: Sequence[int]) -> Sequence[int]:
+ return tagged5(char, convertbits(data8, 8, 5))
+
+
+def int_to_data5(val: int, *, bit_len: int = None) -> Sequence[int]:
+ """Represent big-endian number with as many 0-31 values as it takes.
+ If `bit_len` is set, use exactly bit_len//5 values (left-padded with zeroes).
+ """
+ if bit_len is not None:
+ assert bit_len % 5 == 0, bit_len
+ if val.bit_length() > bit_len:
+ raise ValueError(f"{val=} too big for {bit_len=!r}")
+ ret = []
+ while val != 0:
+ ret.append(val % 32)
+ val //= 32
+ if bit_len is not None:
+ ret.extend([0] * (len(ret) - bit_len // 5))
+ ret.reverse()
+ return ret
+
+
+def int_from_data5(data5: Sequence[int]) -> int:
+ total = 0
+ for v in data5:
+ total = 32 * total + v
+ return total
+
+
+def pull_tagged(data5: bytearray) -> Tuple[str, Sequence[int]]:
+ """Try to pull out tagged data: returns tag, tagged data. Mutates data in-place."""
+ if len(data5) < 3:
+ raise ValueError("Truncated field")
+ length = data5[1] * 32 + data5[2]
+ if length > len(data5) - 3:
+ raise ValueError(
+ "Truncated {} field: expected {} values".format(CHARSET[data5[0]], length))
+ ret = (CHARSET[data5[0]], data5[3:3+length])
+ del data5[:3 + length] # much faster than: data5=data5[offset:]
+ return ret
+
+
+def encode_bolt11_invoice(addr: 'BOLT11Addr', privkey) -> str:
+ if addr.amount:
+ amount = addr.net.BOLT11_HRP + shorten_amount(addr.amount)
+ else:
+ amount = addr.net.BOLT11_HRP if addr.net else ''
+
+ hrp = 'ln' + amount
+
+ # Start with the timestamp
+ data5 = int_to_data5(addr.date, bit_len=35)
+
+ tags_set = set()
+
+ # Payment hash
+ assert addr.paymenthash is not None
+ data5 += tagged8('p', addr.paymenthash)
+ tags_set.add('p')
+
+ if addr.payment_secret is not None:
+ data5 += tagged8('s', addr.payment_secret)
+ tags_set.add('s')
+
+ for k, v in addr.tags:
+
+ # BOLT #11:
+ #
+ # A writer MUST NOT include more than one `d`, `h`, `n` or `x` fields,
+ if k in ('d', 'h', 'n', 'x', 'p', 's', '9'):
+ if k in tags_set:
+ raise BOLT11EncodeException("Duplicate '{}' tag".format(k))
+
+ if k == 'r':
+ route = bytearray()
+ for step in v:
+ pubkey, scid, feebase, feerate, cltv = step
+ route += pubkey
+ route += scid
+ route += int.to_bytes(feebase, length=4, byteorder="big", signed=False)
+ route += int.to_bytes(feerate, length=4, byteorder="big", signed=False)
+ route += int.to_bytes(cltv, length=2, byteorder="big", signed=False)
+ data5 += tagged8('r', route)
+ elif k == 't':
+ pubkey, feebase, feerate, cltv = v
+ route = bytearray()
+ route += pubkey
+ route += int.to_bytes(feebase, length=4, byteorder="big", signed=False)
+ route += int.to_bytes(feerate, length=4, byteorder="big", signed=False)
+ route += int.to_bytes(cltv, length=2, byteorder="big", signed=False)
+ data5 += tagged8('t', route)
+ elif k == 'f':
+ if v is not None:
+ data5 += encode_fallback_addr(v, addr.net)
+ elif k == 'd':
+ # truncate to max length: 1024*5 bits = 639 bytes
+ data5 += tagged8('d', v.encode()[0:639])
+ elif k == 'x':
+ expirybits = int_to_data5(v)
+ data5 += tagged5('x', expirybits)
+ elif k == 'h':
+ data5 += tagged8('h', sha256(v.encode('utf-8')).digest())
+ elif k == 'n':
+ data5 += tagged8('n', v)
+ elif k == 'c':
+ finalcltvbits = int_to_data5(v)
+ data5 += tagged5('c', finalcltvbits)
+ elif k == '9':
+ if v == 0:
+ continue
+ feature_bits = int_to_data5(v)
+ data5 += tagged5('9', feature_bits)
+ else:
+ # FIXME: Support unknown tags?
+ raise BOLT11EncodeException("Unknown tag {}".format(k))
+
+ tags_set.add(k)
+
+ # BOLT #11:
+ #
+ # A writer MUST include either a `d` or `h` field, and MUST NOT include
+ # both.
+ if 'd' in tags_set and 'h' in tags_set:
+ raise ValueError("Cannot include both 'd' and 'h'")
+ if 'd' not in tags_set and 'h' not in tags_set:
+ raise ValueError("Must include either 'd' or 'h'")
+
+ # We actually sign the hrp, then data (padded to 8 bits with zeroes).
+ msg = hrp.encode("ascii") + bytes(convertbits(data5, 5, 8))
+ msg32 = sha256(msg).digest()
+ privkey = ecc.ECPrivkey(privkey)
+ sig = privkey.ecdsa_sign_recoverable(msg32, is_compressed=False)
+ recovery_flag = bytes([sig[0] - 27])
+ sig = bytes(sig[1:]) + recovery_flag
+ sig = bytes(convertbits(sig, 8, 5, False))
+ data5 += sig
+
+ return bech32_encode(segwit_addr.Encoding.BECH32, hrp, data5)
+
+
+class BOLT11Addr:
+ def __init__(self, *, paymenthash: bytes = None, amount=None, net: Type[AbstractNet] = None, tags=None, date=None,
+ payment_secret: bytes = None):
+ self.date = int(time.time()) if not date else int(date)
+ self.tags = [] if not tags else tags
+ self.unknown_tags = []
+ self.paymenthash = paymenthash
+ self.payment_secret = payment_secret
+ self.signature = None
+ self.pubkey = None
+ self.net = constants.net if net is None else net # type: Type[AbstractNet]
+ self._amount = amount # type: Optional[Decimal] # in bitcoins
+
+ @property
+ def amount(self) -> Optional[Decimal]:
+ return self._amount
+
+ @amount.setter
+ def amount(self, value):
+ if not (isinstance(value, Decimal) or value is None):
+ raise BOLT11InvoiceException(f"amount must be Decimal or None, not {value!r}")
+ if value is None:
+ self._amount = None
+ return
+ assert isinstance(value, Decimal)
+ if value.is_nan() or not (0 <= value <= TOTAL_COIN_SUPPLY_LIMIT_IN_BTC):
+ raise BOLT11InvoiceException(f"amount is out-of-bounds: {value!r} BTC")
+ if value * 10**12 % 10:
+ # max resolution is millisatoshi
+ raise BOLT11InvoiceException(f"Cannot encode {value!r}: too many decimal places")
+ self._amount = value
+
+ def get_amount_sat(self) -> Optional[Decimal]:
+ # note that this has msat resolution potentially
+ if self.amount is None:
+ return None
+ return self.amount * COIN
+
+ def get_routing_info(self, tag):
+ # note: tag will be 't' for trampoline
+ r_tags = list(filter(lambda x: x[0] == tag, self.tags))
+ # strip the tag type, it's implicitly 'r' now
+ r_tags = list(map(lambda x: x[1], r_tags))
+ # if there are multiple hints, we will use the first one that works,
+ # from a random permutation
+ random.shuffle(r_tags)
+ return r_tags
+
+ @staticmethod
+ def format_bolt11_routing_info_as_human_readable(r_tags, *, has_explicit_r_tagtype: bool = False):
+ """Converts the node-id bytes->hex, and the SCID bytes->"AAAxBBBxCC", e.g. for logging."""
+ from .util import format_short_id
+ r_tags2 = []
+ for r_tag in r_tags:
+ if has_explicit_r_tagtype:
+ (tagtype, path) = r_tag
+ assert tagtype == "r", f"found unexpected {tagtype=}"
+ else:
+ path = r_tag
+ path2 = [
+ (edge[0].hex(), format_short_id(edge[1]), edge[2], edge[3], edge[4])
+ for edge in path]
+ r_tag2 = (tagtype, path2) if has_explicit_r_tagtype else path2
+ r_tags2.append(r_tag2)
+ return r_tags2
+
+ def get_amount_msat(self) -> Optional[int]:
+ if self.amount is None:
+ return None
+ return int(self.amount * COIN * 1000)
+
+ def get_features(self) -> 'LnFeatures':
+ from .lnutil import LnFeatures
+ return LnFeatures(self.get_tag('9') or 0)
+
+ def validate_and_compare_features(self, myfeatures: 'LnFeatures') -> None:
+ """Raises IncompatibleOrInsaneFeatures.
+
+ note: these checks are not done by the parser (in decode_bolt11_invoice),
+ as then when we started requiring a new feature,
+ old saved already paid invoices could no longer be parsed.
+ """
+ from .lnutil import validate_features, ln_compare_features, LnFeatureContexts
+ invoice_features = self.get_features()
+ validate_features(invoice_features, context=LnFeatureContexts.BOLT11_INVOICE)
+ ln_compare_features(myfeatures.for_bolt11_invoice(), invoice_features)
+
+ def __str__(self):
+ return "BOLT11Addr[{}, amount={}{} tags=[{}]]".format(
+ hexlify(self.pubkey.serialize()).decode('utf-8') if self.pubkey else None,
+ self.amount, self.net.BOLT11_HRP,
+ ", ".join([k + '=' + str(v) for k, v in self.tags])
+ )
+
+ def get_min_final_cltv_delta(self) -> int:
+ cltv = self.get_tag('c')
+ if cltv is None:
+ return 18
+ return int(cltv)
+
+ def get_tag(self, tag):
+ for k, v in self.tags:
+ if k == tag:
+ return v
+ return None
+
+ def get_description(self) -> str:
+ return self.get_tag('d') or ''
+
+ def get_fallback_address(self) -> str:
+ return self.get_tag('f') or ''
+
+ def get_expiry(self) -> int:
+ exp = self.get_tag('x')
+ if exp is None:
+ exp = 3600
+ return int(exp)
+
+ def is_expired(self) -> bool:
+ now = time.time()
+ # BOLT-11 does not specify what expiration of '0' means.
+ # we treat it as 0 seconds here (instead of never)
+ return now > self.get_expiry() + self.date
+
+ def to_debug_json(self) -> Dict[str, Any]:
+ d = {
+ 'pubkey': self.pubkey.serialize().hex(),
+ 'amount_BTC': str(self.amount),
+ 'rhash': self.paymenthash.hex(),
+ 'payment_secret': self.payment_secret.hex() if self.payment_secret else None,
+ 'description': self.get_description(),
+ 'exp': self.get_expiry(),
+ 'time': self.date,
+ 'min_final_cltv_delta': self.get_min_final_cltv_delta(),
+ 'features': self.get_features().get_names(),
+ 'tags': self.tags,
+ 'unknown_tags': self.unknown_tags,
+ }
+ if ln_routing_info := self.get_routing_info('r'):
+ d['r_tags'] = self.format_bolt11_routing_info_as_human_readable(ln_routing_info)
+ return d
+
+
+class SerializableKey:
+ def __init__(self, pubkey):
+ self.pubkey = pubkey
+ def serialize(self):
+ return self.pubkey.get_public_key_bytes(True)
+
+
+def decode_bolt11_invoice(invoice: str, *, verbose=False, net=None) -> BOLT11Addr:
+ """Parses a string into a BOLT11Addr object.
+ Can raise BOLT11DecodeException or IncompatibleOrInsaneFeatures.
+ """
+ if net is None:
+ net = constants.net
+ decoded_bech32 = bech32_decode(invoice, ignore_long_length=True)
+ hrp = decoded_bech32.hrp
+ data5 = decoded_bech32.data # "5" as in list of 5-bit integers
+ if decoded_bech32.encoding is None:
+ raise BOLT11DecodeException("Bad bech32 checksum")
+ if decoded_bech32.encoding != segwit_addr.Encoding.BECH32:
+ raise BOLT11DecodeException("Bad bech32 encoding: must be using vanilla BECH32")
+
+ # BOLT #11:
+ #
+ # A reader MUST fail if it does not understand the `prefix`.
+ if not hrp.startswith('ln'):
+ raise BOLT11DecodeException("Does not start with ln")
+
+ if not hrp[2:].startswith(net.BOLT11_HRP):
+ raise BOLT11DecodeException(f"Wrong Lightning invoice HRP {hrp[2:]}, should be {net.BOLT11_HRP}")
+
+ # Final signature 65 bytes, split it off.
+ if len(data5) < 65*8//5:
+ raise BOLT11DecodeException("Too short to contain signature")
+ sigdecoded = bytes(convertbits(data5[-65*8//5:], 5, 8, False))
+ data5 = data5[:-65*8//5]
+ data5_remaining = bytearray(data5) # note: bytearray is faster than list of ints
+
+ addr = BOLT11Addr()
+ addr.pubkey = None
+ addr.net = net
+
+ amountstr = hrp[2+len(net.BOLT11_HRP):]
+ # BOLT #11:
+ #
+ # A reader SHOULD indicate if amount is unspecified, otherwise it MUST
+ # multiply `amount` by the `multiplier` value (if any) to derive the
+ # amount required for payment.
+ if amountstr != '':
+ addr.amount = unshorten_amount(amountstr)
+
+ addr.date = int_from_data5(data5_remaining[:7])
+ data5_remaining = data5_remaining[7:]
+
+ while data5_remaining:
+ tag, tagdata = pull_tagged(data5_remaining) # mutates arg
+
+ # BOLT #11:
+ #
+ # A reader MUST skip over unknown fields, an `f` field with unknown
+ # `version`, or a `p`, `h`, or `n` field which does not have
+ # `data_length` 52, 52, or 53 respectively.
+ data_length = len(tagdata)
+
+ if tag == 'r':
+ # BOLT #11:
+ #
+ # * `r` (3): `data_length` variable. One or more entries
+ # containing extra routing information for a private route;
+ # there may be more than one `r` field, too.
+ # * `pubkey` (264 bits)
+ # * `short_channel_id` (64 bits)
+ # * `feebase` (32 bits, big-endian)
+ # * `feerate` (32 bits, big-endian)
+ # * `cltv_expiry_delta` (16 bits, big-endian)
+ tagdata = convertbits(tagdata, 5, 8, False)
+ if not tagdata:
+ continue
+ route = []
+ with io.BytesIO(bytes(tagdata)) as s:
+ while True:
+ pubkey = s.read(33)
+ scid = s.read(8)
+ feebase = s.read(4)
+ feerate = s.read(4)
+ cltv = s.read(2)
+ if len(cltv) != 2:
+ break # EOF
+ feebase = int.from_bytes(feebase, byteorder="big")
+ feerate = int.from_bytes(feerate, byteorder="big")
+ cltv = int.from_bytes(cltv, byteorder="big")
+ route.append((pubkey, scid, feebase, feerate, cltv))
+ if route:
+ addr.tags.append(('r',route))
+ elif tag == 't':
+ tagdata = convertbits(tagdata, 5, 8, False)
+ if not tagdata:
+ continue
+ route = []
+ with io.BytesIO(bytes(tagdata)) as s:
+ pubkey = s.read(33)
+ feebase = s.read(4)
+ feerate = s.read(4)
+ cltv = s.read(2)
+ if len(cltv) == 2: # no EOF
+ feebase = int.from_bytes(feebase, byteorder="big")
+ feerate = int.from_bytes(feerate, byteorder="big")
+ cltv = int.from_bytes(cltv, byteorder="big")
+ route.append((pubkey, feebase, feerate, cltv))
+ addr.tags.append(('t', route))
+ elif tag == 'f':
+ fallback = parse_fallback_addr(tagdata, addr.net)
+ if fallback:
+ addr.tags.append(('f', fallback))
+ else:
+ # Incorrect version.
+ addr.unknown_tags.append((tag, tagdata))
+ continue
+
+ elif tag == 'd':
+ addr.tags.append(('d', bytes(convertbits(tagdata, 5, 8, False)).decode('utf-8')))
+
+ elif tag == 'h':
+ if data_length != 52:
+ addr.unknown_tags.append((tag, tagdata))
+ continue
+ addr.tags.append(('h', bytes(convertbits(tagdata, 5, 8, False))))
+
+ elif tag == 'x':
+ addr.tags.append(('x', int_from_data5(tagdata)))
+
+ elif tag == 'p':
+ if data_length != 52:
+ addr.unknown_tags.append((tag, tagdata))
+ continue
+ addr.paymenthash = bytes(convertbits(tagdata, 5, 8, False))
+
+ elif tag == 's':
+ if data_length != 52:
+ addr.unknown_tags.append((tag, tagdata))
+ continue
+ addr.payment_secret = bytes(convertbits(tagdata, 5, 8, False))
+
+ elif tag == 'n':
+ if data_length != 53:
+ addr.unknown_tags.append((tag, tagdata))
+ continue
+ pubkeybytes = bytes(convertbits(tagdata, 5, 8, False))
+ addr.pubkey = pubkeybytes
+
+ elif tag == 'c':
+ addr.tags.append(('c', int_from_data5(tagdata)))
+
+ elif tag == '9':
+ features = int_from_data5(tagdata)
+ addr.tags.append(('9', features))
+ # note: The features are not validated here in the parser,
+ # instead, validation is done just before we try paying the invoice (in lnworker._check_bolt11_invoice).
+ # Context: invoice parsing happens when opening a wallet. If there was a backwards-incompatible
+ # change to a feature, and we raised, some existing wallets could not be opened. Such a change
+ # can happen to features not-yet-merged-to-BOLTs (e.g. trampoline feature bit was moved and reused).
+ else:
+ addr.unknown_tags.append((tag, tagdata))
+
+ if verbose:
+ print('hex of signature data (32 byte r, 32 byte s): {}'
+ .format(hexlify(sigdecoded[0:64])))
+ print('recovery flag: {}'.format(sigdecoded[64]))
+ data8 = bytes(convertbits(data5, 5, 8, True))
+ print('hex of data for signing: {}'
+ .format(hexlify(hrp.encode("ascii") + data8)))
+ print('SHA256 of above: {}'.format(sha256(hrp.encode("ascii") + data8).hexdigest()))
+
+ # BOLT #11:
+ #
+ # A reader MUST check that the `signature` is valid (see the `n` tagged
+ # field specified below).
+ addr.signature = sigdecoded[:65]
+ hrp_hash = sha256(hrp.encode("ascii") + bytes(convertbits(data5, 5, 8, True))).digest()
+ if addr.pubkey: # Specified by `n`
+ # BOLT #11:
+ #
+ # A reader MUST use the `n` field to validate the signature instead of
+ # performing signature recovery if a valid `n` field is provided.
+ if not ecc.ECPubkey(addr.pubkey).ecdsa_verify(sigdecoded[:64], hrp_hash):
+ raise BOLT11DecodeException("bad signature")
+ pubkey_copy = addr.pubkey
+
+ class WrappedBytesKey:
+ serialize = lambda: pubkey_copy
+
+ addr.pubkey = WrappedBytesKey
+ else: # Recover pubkey from signature.
+ addr.pubkey = SerializableKey(ecc.ECPubkey.from_ecdsa_sig64(sigdecoded[:64], sigdecoded[64], hrp_hash))
+
+ return addr
diff --git a/electrum/chains/mainnet/checkpoints.json b/electrum/chains/mainnet/checkpoints.json
new file mode 100644
index 000000000000..4d47920f97a4
--- /dev/null
+++ b/electrum/chains/mainnet/checkpoints.json
@@ -0,0 +1,1858 @@
+[
+ [
+ "00000000693067b0e6b440bc51450b9f3850561b07f6d3c021c54fbd6abb9763",
+ 26959535291011309493156476344723991336010898738574164086137773096960
+ ],
+ [
+ "00000000f037ad09d0b05ee66b8c1da83030abaf909d2b1bf519c3c7d2cd3fdf",
+ 26959535291011309493156476344723991336010898738574164086137773096960
+ ],
+ [
+ "000000006ce8b5f16fcedde13acbc9641baa1c67734f177d770a4069c06c9de8",
+ 26959535291011309493156476344723991336010898738574164086137773096960
+ ],
+ [
+ "00000000563298de120522b5ae17da21aaae02eee2d7fcb5be65d9224dbd601c",
+ 26959535291011309493156476344723991336010898738574164086137773096960
+ ],
+ [
+ "000000009b0a4b2833b4a0aa61171ee75b8eb301ac45a18713795a72e461a946",
+ 26959535291011309493156476344723991336010898738574164086137773096960
+ ],
+ [
+ "00000000fa8a7363e8f6fdc88ec55edf264c9c7b31268c26e497a4587c750584",
+ 26959535291011309493156476344723991336010898738574164086137773096960
+ ],
+ [
+ "000000008ac55b5cd76a5c176f2457f0e9df5ff1c719d939f1022712b1ba2092",
+ 26959535291011309493156476344723991336010898738574164086137773096960
+ ],
+ [
+ "000000007f0c796631f00f542c0b402d638d3518bc208f8c9e5d29d2f169c084",
+ 26959535291011309493156476344723991336010898738574164086137773096960
+ ],
+ [
+ "00000000ffb062296c9d4eb5f87bbf905d30669d26eab6bced341bd3f1dba5fd",
+ 26959535291011309493156476344723991336010898738574164086137773096960
+ ],
+ [
+ "0000000074c108842c3ec2252bba62db4050bf0dddfee3ddaa5f847076b8822f",
+ 26959535291011309493156476344723991336010898738574164086137773096960
+ ],
+ [
+ "0000000067dc2f84a73fbf5d3c70678ce4a1496ef3a62c557bc79cbdd1d49f22",
+ 26959535291011309493156476344723991336010898738574164086137773096960
+ ],
+ [
+ "00000000dbf06f47c0624262ecb197bccf6bdaaabc2d973708ac401ac8955acc",
+ 26959535291011309493156476344723991336010898738574164086137773096960
+ ],
+ [
+ "000000009260fe30ec89ef367122f429dcc59f61735760f2b2288f2e854f04ac",
+ 26959535291011309493156476344723991336010898738574164086137773096960
+ ],
+ [
+ "00000000f9f1a700898c4e0671af6efd441eaf339ba075a5c5c7b0949473c80b",
+ 26959535291011309493156476344723991336010898738574164086137773096960
+ ],
+ [
+ "000000005107662c86452e7365f32f8ffdc70d8d87aa6f78630a79f7d77fbfe6",
+ 26959535291011309493156476344723991336010898738574164086137773096960
+ ],
+ [
+ "00000000984f962134a7291e3693075ae03e521f0ee33378ec30a334d860034b",
+ 22791060871177364286867400663010583169263383106957897897309909286912
+ ],
+ [
+ "000000005e36047e39452a7beaaa6721048ac408a3e75bb60a8b0008713653ce",
+ 20657664212610420653213483117824978239553266057163961604478437687296
+ ],
+ [
+ "00000000128d789579ffbec00203a371cbb39cee27df35d951fd66e62ed59258",
+ 20055820920770189543295303139304627292355830414308479769458683936768
+ ],
+ [
+ "000000008dde642fb80481bb5e1671cb04c6716de5b7f783aa3388456d5c8a85",
+ 14823939180767414932263578623363531361763221729526512593941781544960
+ ],
+ [
+ "000000008135b689ad1557d4e148a8b9e58e2c4a67240fc87962abb69710231a",
+ 10665477591887247494381404907447500979192021944764506987270680608768
+ ],
+ [
+ "00000000308496ef3e4f9fa542a772df637b4aaf1dcce404424611feacfc09e7",
+ 7129927859545590787920041835044506526699926406309469412482969763840
+ ],
+ [
+ "000000001a2e0c63d7d012003c9173acfd04ccd6372027718979228c461b5ed5",
+ 5949911473257063494842414979623989957440207170696926280907451531264
+ ],
+ [
+ "000000002e0c0ac26ccde91b51ab018576b3a126b413e9f6f787b36637f1b174",
+ 5905492491837656485645884063467495540781288435542782321354050895872
+ ],
+ [
+ "00000000103226f85fe2b68795f087dcec345e523363f18017e60b5c94175355",
+ 4430143390146946405787502162943966061454423600514874825749833973760
+ ],
+ [
+ "000000001ae6f66fd4de47f8d6f357e798943bbfc4f39ebf14b0975fab059173",
+ 3447600406241317909690675945127070282093452846402311540118831235072
+ ],
+ [
+ "000000000a3f22690162744d3bc0b674c92e661a25afb3d2ac8b39b27ac14373",
+ 2351604382534916182160036119666703740669209516522695514729880748032
+ ],
+ [
+ "0000000006dc436c3c515a97446af858c1203a501c85d26c4a30afa380aba4a1",
+ 2098151686442211199940455690614286210348997571531298297574806519808
+ ],
+ [
+ "000000000943fe1680ffcc498ce50790ff8e842a8af2c157664e4fbc1cb7cb46",
+ 2275790652544821279950241890112140030244814501479017131553197129728
+ ],
+ [
+ "000000000847b2144376c1fb057ea1d5a027d5a6004277ed4c72422e93df04e9",
+ 1622203955679450683159610732218403647246163922223729367236739072000
+ ],
+ [
+ "00000000094505954deb1d31382b86d0510fd280a34143400b1856a4d52b4c93",
+ 1551048739079662593758612650769536967206480773659027300489594142720
+ ],
+ [
+ "000000000109272cecb3f7e98ac12cf149fa8a1b2aaab248e1b006b0dc595a3a",
+ 1389323280429349294447518501872137680563441219958739463959193059328
+ ],
+ [
+ "0000000009e6aa0fe39b790625ffeb18a2d6ff5060a5bd14e699e83c54109977",
+ 1147152896345386682952518188670047452875537662186691235300769792000
+ ],
+ [
+ "0000000000d14af55c4eae0121184919baba2deb8bf89c3af6b8e4c4f35c8e4e",
+ 594007861936424273334637371358095438347537381057796937154824241152
+ ],
+ [
+ "0000000003dfbfa2b33707e691ab2ab7cda7503be2c2cce43d1b21cd1cc757fb",
+ 148501965484106068333659342839523859586884345264449234288706060288
+ ],
+ [
+ "0000000000c169d181d66d242901f70d006f3e088c1ae9cacb88b94b8266e9c3",
+ 110393429764504113949181711819653188468070301266890302199533928448
+ ],
+ [
+ "000000000009f7d1439d6a2fc1a456db8e843674275bf0133fc7b43b5f45b96e",
+ 76554528428498296726819074079132986384157750623812250673757552640
+ ],
+ [
+ "000000000011b8a8fad7973548b50f6d4b2ba1690f7487c374e43248c576354f",
+ 52678642966898219212816601311127992435882858542187514726849708032
+ ],
+ [
+ "000000000077e856b6cc475d9cf784119811214c9cac8d7b674ec24faa7c2c0c",
+ 43246870766561725070861386869077695524372774526710079316876591104
+ ],
+ [
+ "00000000004cbb474f2cbf3a65f690efa09804512af3351ba3a0888c806c6625",
+ 37817516728945957090904676150631917288430706594442690521085247488
+ ],
+ [
+ "0000000000235b1ec6656d8e91f3dde3b6ab9ad7e75b332e4da9355ce60d860e",
+ 29373101246077110899697012205905070265841442578602225419818106880
+ ],
+ [
+ "00000000002a153a2c95a8e5493db93086b0e3fe590b636a5871ace57523ef93",
+ 20444488966645742314409346972440253478913291170842138088329707520
+ ],
+ [
+ "00000000000e9550e084908cf91a4e8b74f9f1315d1bc4020709f9e7f261bb18",
+ 19563849255781403323327768731100757126732627316116500830377476096
+ ],
+ [
+ "00000000002c2cfef3bb85b463d3fcd39b73a6d3d5ae11c1e2a8113e3794f28d",
+ 12545026348036226200394850922278603223904369245268262607334146048
+ ],
+ [
+ "00000000000fa92b757ee29674aa97e98a49ba3ad340d2baa94155d71648dfe1",
+ 8719867261221084516486306056196045840260667577454435863762042880
+ ],
+ [
+ "0000000000030571601dbc8e13d00d45004eee6ea8b6ab3cdfb38d2546fee21c",
+ 5942996718418989293499865695368015163438891473576991811912597504
+ ],
+ [
+ "00000000000bb6adef42e63082b20fd2b1dc1b324c51973512a4c31f29a9986e",
+ 3926013280397599483741094494745234959951218212740030386090803200
+ ],
+ [
+ "000000000000765094788a98dbb8adac30d248b7129b59b1441ee2b7ef9e332f",
+ 3337321571246095014985518819479127172783474909736415373333364736
+ ],
+ [
+ "00000000000431a0aa9625f82975709f3c6f4f64d04c559512af051599872084",
+ 2200419182034594781720344474937177839165432393990533906392154112
+ ],
+ [
+ "00000000000292b850b8f8578e6b4d03cbb4a78ada44afbb4d2f80a16490e8f9",
+ 1861311314983800126815643622927230076368334845814253369901973504
+ ],
+ [
+ "0000000000025afe84e27423011af25f777e5a94545dbd00fd04bebe9050f7dd",
+ 1653206561150525499452195696179626311675293455763937233695932416
+ ],
+ [
+ "0000000000000e389cccae2a40437be574fd806909e24136711e7f8bce671d65",
+ 1462200632444444190489436459820840230299714881944341127503020032
+ ],
+ [
+ "0000000000030510bf6bc1649726cf2e6e4010c64a2c8fd3fde5dc92535ca40e",
+ 1224744150896501443874292381730317417444978877835711165914677248
+ ],
+ [
+ "00000000000082648057f14fc835779c6ce46a407bafb2e5c2ac1d20d9f4e822",
+ 1036989760889350435547200084292752907272941324136347429599444992
+ ],
+ [
+ "000000000000f38accd6b22959010471a6d9f159d43bf2a9d4c53c220201254e",
+ 739430030225080220618328322475016688484025266646974337550123008
+ ],
+ [
+ "0000000000004ed7a73133678b5eb883cd8882bf14dfb26c104ae0c3f94cf4ee",
+ 484975157177710342494716926626447514974484083994735770500857856
+ ],
+ [
+ "00000000000037bb3ff4cf649a1757d4028ecc10f893529b4a2214792c981f96",
+ 353833947722011807976659613996792948209273674048993161457434624
+ ],
+ [
+ "0000000000008008f46559fe7f181e9dc0648f213472a1e576e8bf506b88f22f",
+ 390843739553851677760235428436025349398613161749553108945469440
+ ],
+ [
+ "000000000000691d0c2444db713bf6c088844cc95a37cdc55cc269bb0a31d8c8",
+ 327394795212563108599383268946242257264650552916910648089116672
+ ],
+ [
+ "00000000000071153b0afcc64a425f8442c29749610797119e732dd4b723f675",
+ 291935447509363748964474894494542149680088347011133317125767168
+ ],
+ [
+ "000000000000a384acb522e4e5935ad2bc31366ecf1f16f1f11023e967ef033d",
+ 245823858161213192073337185391658632187400443916100519594033152
+ ],
+ [
+ "0000000000002e532093d43e901292121fb7c6583caf2d13b666fe7e194b4a97",
+ 171262555713783851185422181139260521316022447660158187451973632
+ ],
+ [
+ "00000000000033e435c4bbddc7eb255146aa7f18e61a832983af3a9ee5dd144d",
+ 110438984653392107399822606842181601255647711092336854093004800
+ ],
+ [
+ "00000000000028ff4b0bd45f0e3e713f91fa1821d28a276a1a1f32f786662f13",
+ 61993465896324436412959469550829248888675813063783317791309824
+ ],
+ [
+ "0000000000001ef9c75318e116a607af4de68fb4f67c788677ee6779fb5fa0d5",
+ 47525089675259291211422247200069659468817014361857087365971968
+ ],
+ [
+ "0000000000000e6e98694ccb8247aad63aaa1e2bec5a7be14329407e4cea6223",
+ 30742228348699538311994447367921718297595975288392383715082240
+ ],
+ [
+ "000000000000000a2153574b2523a6d1844c3cb82d085e2575846dd8c5d4ebb4",
+ 19547336162709893274575855467812492508787617050928192350584832
+ ],
+ [
+ "00000000000002a92c1b1ffb2a8388979cf30798e312335ae2a1b922927ee83d",
+ 17248274092338559882155796390905381469049315669915374897332224
+ ],
+ [
+ "00000000000004d54b1422ce733922e7672a4e2ecc86dcf96c0de06565cddaa6",
+ 15943936487596784557029840069157210316687734428242467413295104
+ ],
+ [
+ "00000000000009dd91ae96cbbf67af42340b0bc715b3606aa725f630b470262d",
+ 14273467308195657992975774342458504496649432985410431166185472
+ ],
+ [
+ "00000000000007d33d78522fa95bdcd4a25072aeac844cbe9b6bc5d0cc885d0a",
+ 14930233597189143322113827544414041000381079823613435714732032
+ ],
+ [
+ "00000000000003dd57f5dd1228f68390b586700063225d26bac972bd120546d2",
+ 15164766714763258952996988973449124317842091658872414191747072
+ ],
+ [
+ "000000000000076bdeca878b47c392f51fbda543b1e69612cf7d305deb537604",
+ 15357836632983707094928406965317628870031114888441593128288256
+ ],
+ [
+ "00000000000008eb1bb7e18d9dfe62210d761cbf114d59ca08e4f638b8563e30",
+ 15958672964717750944291813934170287689797412223641384931819520
+ ],
+ [
+ "00000000000001b0d8d885e4d77d7c51e8f1fdaba68f229ac04d191915845f09",
+ 18362361570655080300849714079315004638119732162003921272832000
+ ],
+ [
+ "000000000000081baa3a716d5f9ab072c9fc3b798900234c9be23ab02a287c30",
+ 22401652017447755518156310839596703571934659990690572544245760
+ ],
+ [
+ "00000000000005b88d0224b9b0d4b65d3de9a61d93609bb91c9297440f1c4657",
+ 22607619418140130980719672680045705126213018528712048676700160
+ ],
+ [
+ "000000000000027d6a6870403fa43a650b7d9a6e61243f375a79ea935ad9ef1f",
+ 24717289559589094364468373797949472355802981654048927838633984
+ ],
+ [
+ "0000000000000810a3490b86e4f302f6557f9621c5c8620c2b09ec8f0cf72794",
+ 23340814324747679919001773364939281849550099124416593832968192
+ ],
+ [
+ "000000000000073833bca8d0ea909fde717e251576b7b3ccaaa58ad5d39eed60",
+ 23242391331131109072962566885467580392541369223033474166816768
+ ],
+ [
+ "000000000000031b7fd2ed1f28ff74e969aa891297706c38bd2e1d3bc48183c4",
+ 21554562042243053719921017803645315870071034703425342074257408
+ ],
+ [
+ "0000000000000b0738bcba382983811d40b531f2e68cd57126092755f1be4ba6",
+ 20615546854515052444405957679617344022137222968655050411343872
+ ],
+ [
+ "000000000000000664cbfd5e3fa497c07614c33a0934b83e01fbe980634a9aa4",
+ 19540887421473929614259883543522244007742949396702043752628224
+ ],
+ [
+ "000000000000021eb520df39289a70e40c59822a8c47924dc4940e7d0c1455c4",
+ 19588382523276445241758125434587686389961661359576757951266816
+ ],
+ [
+ "0000000000000275e0c41b11bc250fe887c5e60c8ebaaa449f5c28c67133d496",
+ 18009299117968233362105684657812007807160912568078774269116416
+ ],
+ [
+ "000000000000097fb0fdbeee0cee7e8f4e1a4ef8fad49f3d549624b0d47abed0",
+ 17993483763986497389087426516491816616385967180337839494660096
+ ],
+ [
+ "000000000000053f199ae19d34365277e534f978ea2f6c69cd4757a4fc099af5",
+ 16574638092431222848464934504874974361824393751455373256032256
+ ],
+ [
+ "0000000000000217b2e7b4f61682d24b9357d62ad29f27ed45ea2a32dc1f32f6",
+ 17085559845791583266730740536950670241169412424878408752693248
+ ],
+ [
+ "000000000000039c1d77acd4702393f48ca61983c64fc0209ade141c694b2359",
+ 17870687961287995446644888885900316642120964851955511819501568
+ ],
+ [
+ "0000000000000ae53f0c78330f6c2fbece2752909bc3742823e4fab29c5fd2b0",
+ 15554707140145502641228553657813466188995512591033787398225920
+ ],
+ [
+ "00000000000004b4d72b8631a85ec7d226dc696f1913ba1bf735b7c8dec207b8",
+ 16944226977030767532657500340718760127019357828074148225613824
+ ],
+ [
+ "00000000000006e06735bffb7d2f215dcadd8311fc33f4a46661fdca3dc0560e",
+ 17028747171100603034973679895960153979114298528140818252824576
+ ],
+ [
+ "000000000000055fc0110d4a38ffb338eabc30c8b0aef355d4643d21b5b6a860",
+ 15614535766060906942258863525753414259523988166363835227176960
+ ],
+ [
+ "000000000000081b69cb4de006c14084c4861f0e4a140c37200117a738733fe8",
+ 15392654931672180089790308609774483894682932641297604569726976
+ ],
+ [
+ "00000000000009920770f2d40b5b6a8aba33d969b855c91b0f56e3db9c27e41a",
+ 14444739009842829731785903206212823051010663269705670545375232
+ ],
+ [
+ "0000000000000791dd1cb7a684a54c72ccde51f459fff0fc3e6e051641b1e941",
+ 13237058963854547748734324548161076199478283141947127217782784
+ ],
+ [
+ "000000000000019da474a1a598b5cf28534b7fd9b214eed0f36c67c203a9b449",
+ 12305424274651356593961118223415860240572779254789271782948864
+ ],
+ [
+ "000000000000074333e888bac730f9772b65e4cc9d07edb122c6e3c6606bc8ab",
+ 11046080738989403765716562970384822165842244193743674858799104
+ ],
+ [
+ "000000000000067080669115c445f378f3dec19787558d0e03263b9dec5d7720",
+ 10007073282210984973971337419529346944295676968729147521105920
+ ],
+ [
+ "0000000000000304760bf583f4ac241c5ffe77312fa213634eba252c720530f1",
+ 9412783771427520201810837309176674245361798887059324066070528
+ ],
+ [
+ "000000000000041fb61665c8a31b8b5c3ae8fe81903ea81530c979d5094e6f9d",
+ 8825801199382903987726989797449454220615414953524072026210304
+ ],
+ [
+ "000000000000022fc7f2a5c87b2bab742d71c4eb662d572df33f18193d6abf0e",
+ 8774971387283464186072960143252932765613148614319486309236736
+ ],
+ [
+ "000000000000013c6d43ba38bc5f24e699515b9d78602694112fefdc64606640",
+ 8158785580212107593904235970576336449063725988071903546310656
+ ],
+ [
+ "00000000000001665176b9a810fddf27cca60dfcfd80bf113289fcc8ffed0284",
+ 8002789794116287035234223109988652176644807295346590313611264
+ ],
+ [
+ "00000000000002dc6ef80f56a00f1091471d942ce9bfb656ebdab4ea0b77eb0b",
+ 7839560629067579481152758851432818444879208153964570478641152
+ ],
+ [
+ "00000000000002a1fa5546ec48ca88b9e5710e2c6d895bb3675004fdacd6ab13",
+ 7999430563890709006856701613305138698914315019190763857641472
+ ],
+ [
+ "00000000000000f517517c11e649b98feca7da84ae44fb643de5a86798fe3c31",
+ 9047927233058169382412882048952728634925849476849852060008448
+ ],
+ [
+ "0000000000000299cab92a923348acf9251f656bcbacdb641fd0a66d895a6e8f",
+ 8296391419817537486273948666838217011279219811331013552898048
+ ],
+ [
+ "000000000000027508b977f72c3a0f06f1f36e311ad079536630661880934501",
+ 9081029136740872581753422344739175313292014241889017867010048
+ ],
+ [
+ "00000000000001925959229452cc6fbfef0104ebed7ccd6f584f2439c5dd1f1b",
+ 8230751570811169734692743946971314968326461977249645504495616
+ ],
+ [
+ "00000000000003b34ca89509da5f558af468c194afaa8d458bbeb07c50cc7c74",
+ 7384127474250891166670391848516180960454656786677558849568768
+ ],
+ [
+ "0000000000000076559e314ab0c86cc552e34fd79488415d3d17f6ea3c01adb3",
+ 6172230000534146257480611019445716458048957888854766248787968
+ ],
+ [
+ "000000000000003a58043252cdc30ed2f37fb17e6ef1658324b1478f16c1463b",
+ 5561365017980676031428107027647386014985059524839404952616960
+ ],
+ [
+ "000000000000011babf767e60240658195b693711c217d7da0d9215ccab45333",
+ 4026319404534786334009451711043898716884778820756489262596096
+ ],
+ [
+ "000000000000027579d28fb480ccad8e2516d1219d4c1919e3fd4fc0c882955d",
+ 3513558656525386849113615662535622466519417660386833443323904
+ ],
+ [
+ "0000000000000074546fe07f80ba15fc81897ec56a5535de727df9fda9dab500",
+ 3004083578955603829930099910053556479043735076695139267117056
+ ],
+ [
+ "00000000000000b6c55833b80c07894f4c4d3bb686e5ddbc1b1d162e22752ca3",
+ 2675541054922611112919804040984964595022815308724929898217472
+ ],
+ [
+ "00000000000001326f2f970753122e35bfdf3358d046ddf5ea22e57f5d82b00d",
+ 2409843108029446766213067266805752590003732794677225687351296
+ ],
+ [
+ "00000000000000641084745613912464ff73c974bafd0bf6dd306295f019d306",
+ 2218268905456883731807407021635746739577921454491297946533888
+ ],
+ [
+ "000000000000011ae105ddb1a5bbac6931a6578d95c201525f3a945276a64559",
+ 1727551573307299192250197436766000536509732237655131060961280
+ ],
+ [
+ "00000000000000d9b66fee19af89eaaf3f3933d1acd2617924c107f0abbe0a41",
+ 1394031503757574068227953656553224448260418805016069352194048
+ ],
+ [
+ "0000000000000011956d42670c2f75eeb344ac0657a806775998e2c58fa4b157",
+ 1263610003247723462826224891154624535497729630761756072607744
+ ],
+ [
+ "00000000000000959b1ea990368fd16d494e68ee13bd7245ddd9cdfba3330100",
+ 1030450001678223668360152541055867895065240185756254103142400
+ ],
+ [
+ "0000000000000091f86b1e423e24fe358c72db181cfcc2738c85f2f51871a960",
+ 862513010327976103705811440432628413487564277790886242287616
+ ],
+ [
+ "0000000000000055e146e473b49fe656a1f2f4b8c33e72b80acc18f84d9fcc26",
+ 720982641204331278205950312227594303241470815982254303477760
+ ],
+ [
+ "000000000000004f6a191a3261274735292bc30a1f79f23a143e4ee7dd2f64c1",
+ 530591525189316709998942710962548491505413142398652303540224
+ ],
+ [
+ "000000000000005327c8e714272803c60277333362e74ec88b9ffab5410c2358",
+ 410030579894253754102159787320079652501746816512444002729984
+ ],
+ [
+ "0000000000000002e2a62b8705564c38d6a746fc8e971a450a69989152b5ee97",
+ 310118479516817784682897231521434079438159381558537557639168
+ ],
+ [
+ "00000000000000202bf3ff30109538bfd9b5075c6438ab5ef64ebe2cf9b61404",
+ 239366800071949252578530950352093786414793290792735831228416
+ ],
+ [
+ "000000000000001c997105893f5991cb45765ff856b6e503f8466cb22cdd330a",
+ 181156297885756721946540202079438048595571151633323613224960
+ ],
+ [
+ "0000000000000010c13ce182a3d8fc6748b75640447eb360d7739a5fe984ffc1",
+ 142431093377788751676361246670241704468765375727695350988800
+ ],
+ [
+ "000000000000000bbb49db68b79ecc8393376d78272d237bb612288af64c1de8",
+ 100696259189502783924473792493100546893980348528488767029248
+ ],
+ [
+ "0000000000000001bbfd0973c367d30eef2416d9e94bdddea53bccf541a4858f",
+ 68962778243821519216393853205209897734463141354237780295680
+ ],
+ [
+ "0000000000000004ee5b6ace996ab746f1e6dd952cdbc74c0b4f8b9ac51c7335",
+ 52765641310467331636297188681879886184148735229489015947264
+ ],
+ [
+ "0000000000000002f2f23b515085d0c9f37a2824304ccb7ca1546a48548d0dac",
+ 44233472386696495417387091608220539804351405166731810832384
+ ],
+ [
+ "00000000000000045590c3fdeca1753d148a87614a70fa0897a17f90bb321654",
+ 38110290672195532365762668664552282566878756832852091863040
+ ],
+ [
+ "0000000000000002b704edc0bf1435fe2116040b547adb1bc2d196eb81779834",
+ 29679649578007061283718812081441644170496168236939550392320
+ ],
+ [
+ "00000000000000038cc59dc6dd68ae0fbe2ded8a3de65dbd9a2f9a36d26772df",
+ 22829202948393929850749706076701368331072452018388575715328
+ ],
+ [
+ "0000000000000000a979bc50075e7cdf0da5274f7314910b2d798b1aeaf6543f",
+ 19005913916847449503306572434028937600915626422125897711616
+ ],
+ [
+ "0000000000000001dd8e548c8cf5b77cde6e5631cd542e39f42c41952e5e7085",
+ 15065005852539512185984435657022720640916062598235628240896
+ ],
+ [
+ "0000000000000002513542a461de351a5a94f96b4bcd3e324a48d2d71b403fe0",
+ 12288698618318346282960995223961541766142764336009759948800
+ ],
+ [
+ "000000000000000150cc07163e78d599a7e56c0d1040641bffb382705ac17df0",
+ 10284386012808371892335572105827331142617405906583881252864
+ ],
+ [
+ "00000000000000009051d83d276dad5c547612f67c2907acf6a143039bddb1bb",
+ 8614444778121073626993210829679478604092861119379437256704
+ ],
+ [
+ "00000000000000000b83d3947d2790ab0bcbbb61eba1eb8d8f0f0eb3e9d461e0",
+ 7065379129219572345353864175298106702426244380437224882176
+ ],
+ [
+ "00000000000000005a4fbbaeffee6d52fa329dd8c559f90c9b30264c46ad33fd",
+ 6343094824615218102798845742064326605321937397913065881600
+ ],
+ [
+ "00000000000000006b6834bae83e895a78c5026a8c8141388040d90506cf3148",
+ 5384518863803604621895699676581808210968416076987222720512
+ ],
+ [
+ "0000000000000000bf3c066c9acdb008e7fff3672f1391b35c8877b76b9e295e",
+ 4405349994161605759458363322921957536960017949107037405184
+ ],
+ [
+ "00000000000000006bcf448b771c8f4db4e2ca653474e3b29504ec08422b3fba",
+ 3863038134637689339706803268689141874606936642244315185152
+ ],
+ [
+ "000000000000000098686ab04cc22fec77e4fa2d76d5a3cc0eb8cbf4ed800cdc",
+ 3369574570478873127315415525946742317481702644901195284480
+ ],
+ [
+ "000000000000000036cc637d80982595b1fa30f877efe8904965e6fd70aeae1a",
+ 3045099693687311168583241534842989903432036285033490677760
+ ],
+ [
+ "00000000000000000ee9b585e0a707347d7c80f3a905f48fa32d448917335366",
+ 2578448441038522347123624842639328775756428679710156783616
+ ],
+ [
+ "00000000000000000401800189014bad6a3ca1af029e19b362d6ef3c5425a8dc",
+ 2293149852232440455888971398133692017055281498246925516800
+ ],
+ [
+ "00000000000000001b44d4645ac00773be676f3de8a8bff1a5fdd1fb04d2b3b2",
+ 2002553378451099534811946324256852041059202347552707969024
+ ],
+ [
+ "00000000000000003ff2a53152ee98910d7383c0177459ad258c4b2d2c4d4610",
+ 1602972750958019380418919163663316163747908621623690788864
+ ],
+ [
+ "00000000000000001bb242c9463b511b9e6a99a6d48bd783acb070ca27861c2b",
+ 1555090122338762644529309082074529684497336694348804259840
+ ],
+ [
+ "000000000000000019d43247356b848a7ef8b1c786d8c833b76e382608cb59e9",
+ 1438882362326364789097016808333128944459434864174551793664
+ ],
+ [
+ "00000000000000003711b624fbde8c77d4c7e25334cfa8bc176b7248ca67b24b",
+ 1366448002777625511026173062127977611952455397852592472064
+ ],
+ [
+ "0000000000000000092c1f996e0b6d07fd0e73dfe6409a5c2adc1206e997c3a2",
+ 1130631509982695295834811811892052032638591596239280668672
+ ],
+ [
+ "000000000000000020ce180d66df9d3c28aee9fcec7896071ec67091a9753283",
+ 982897592923314645728937741958820396011314229953349812224
+ ],
+ [
+ "000000000000000018d37d53ae02e13634eefb8d9246253e99c1bdf65ac293ea",
+ 903780639904017349860452775965599807564731663176966340608
+ ],
+ [
+ "00000000000000001607d1a21507dea1c0e5f398daf94d35fb7e0a3238f96a0f",
+ 777796486219054632155478957346406689849105796561635377152
+ ],
+ [
+ "00000000000000001acae244523061f650ddab9c3271d13c0cd86071ae6e8a5f",
+ 770217816864616291160628694313702426464491250746461782016
+ ],
+ [
+ "0000000000000000104430189dba1219b0e3dd90824e8c2271609aca5b71250f",
+ 749174812297985386116525053725808178560617045558724395008
+ ],
+ [
+ "00000000000000001aa260733b6d8f8faa2092af35e55973278bb17f8eaeca6b",
+ 680733321990486529407107157001552378184394215934016880640
+ ],
+ [
+ "000000000000000009925ad5866a9cb3a1d83d9399137bccc7b5470b38b1db2b",
+ 668970595596618687654683311252875969389523722950049529856
+ ],
+ [
+ "00000000000000001133acacb92e43e24af63a487923361a4a98c87a5550dffe",
+ 673862533877092685902494685124943911912916060357898797056
+ ],
+ [
+ "000000000000000018c66b4a76ca69204e24ee069da9368c7a9883adb36c24af",
+ 683252062220249508849116041812776958610205092831121375232
+ ],
+ [
+ "000000000000000010b13aed220b96c35ccd5f07125b51308db976eefcd718f9",
+ 663358803453687177159928221638562617962497973903752691712
+ ],
+ [
+ "0000000000000000031b14ece1cfda0e23774e473cd2676834f73155e4f46a2b",
+ 613111582105360026820898034285227810088764320248934432768
+ ],
+ [
+ "000000000000000010bfa427c8d305d861ab5ee4776d87d6d911f5fb3045c754",
+ 653202279051259096361833571150520065936493508031976308736
+ ],
+ [
+ "000000000000000005d1e9e192a43a19e2fbd933ffb27df2623187ad5ce10adc",
+ 606439838822957553646521558653356639834299145437709336576
+ ],
+ [
+ "00000000000000000f9e30784bd647e91f6923263a674c9c5c18084fe79a41f8",
+ 577485176368838834686684127480472050622611986764206702592
+ ],
+ [
+ "00000000000000000036d3e1c36e4b959a3e4ad6376ce9ae65961e60350c86e8",
+ 568436119447114618883887501211268589217582000336195813376
+ ],
+ [
+ "00000000000000000b3ec9df7aebc319bb12491ba651337f9b3541e78446eca8",
+ 577075114085443079269506210404847846798089003835028668416
+ ],
+ [
+ "000000000000000012d24ce222e3c81d4c148f2bce88f752c0dba184c3bc6844",
+ 545227566982404669720599751103563308707559049533419683840
+ ],
+ [
+ "000000000000000000c4ccbdd98c267bd16bda12b63b648c47af3ac51c1cc574",
+ 566251116039239425785056264238964437451875594947144974336
+ ],
+ [
+ "00000000000000000056bfec1dca8e82710f411af64b1d3b04a2d2364a81993f",
+ 565860883410058976058672534759150528155363303710710038528
+ ],
+ [
+ "00000000000000001275d1cadce690546f74f77f6d4a6190e2137a8a819946f6",
+ 552364745922238091561919045022000637317595931246011088896
+ ],
+ [
+ "000000000000000003816ae80c6413b84cbee2f639ba497ab5872ec9711eb256",
+ 566500670366816952120145379831520408210047884740723212288
+ ],
+ [
+ "00000000000000000d92953224570f521b09553194da1ca3c4b31a09a238f4f6",
+ 542528489142608155505707877213460200687386787807972294656
+ ],
+ [
+ "000000000000000006721943f23cfacf20c17c2ad6ea4e902af36b01f92e3c06",
+ 545717322027080804612101478705745866012577831152301113344
+ ],
+ [
+ "0000000000000000031d9af2fe38cc02410361fb213181fdb667c74e210d54c4",
+ 527827980769521817826567786138322798799309668948178370560
+ ],
+ [
+ "0000000000000000142e8a13ef6994961655c8e86aece3f0abebd2ee05473e75",
+ 515692606534173891771672037645739723025219384908133171200
+ ],
+ [
+ "00000000000000000c7a8db37a746d6637ef6a6eab28735608fd715ee2f394e7",
+ 511567664312971151375333957573881285830542480898837708800
+ ],
+ [
+ "000000000000000007854877c66c71a49af40d20f2d6f817becfe4d66d5e5a81",
+ 496889230460615059653870414954457230681194245244172894208
+ ],
+ [
+ "000000000000000005ce1d2d10aeb9def4d38233e859d98a4a168ea3fa36687a",
+ 473325989086544548323169648982069700877697035484407005184
+ ],
+ [
+ "000000000000000007c71decfe74855ad99dc2aa4a2e713165db5a8d6da5f32a",
+ 454358737757395076722955683517864397151243915416267915264
+ ],
+ [
+ "000000000000000008ce4f34161be6760569877c685e37ebebce3546ea42a767",
+ 443316987659242217350916733941384923365365929826941140992
+ ],
+ [
+ "0000000000000000086233f4843682eb47bacb58930a5577fbfd5c9ebd57ddf9",
+ 442802913227320896234856097023585967110900073490544590848
+ ],
+ [
+ "000000000000000010a904eee4fc763c6b88d378884f368fd652f63c1af71580",
+ 433057199397126884276233483897801969646324654385408245760
+ ],
+ [
+ "00000000000000000c114754749d622d4fa2f78c84d7147c345b2b99a8e83d2e",
+ 409419129139225030716120689261979366152221060879441985536
+ ],
+ [
+ "000000000000000000a5039e32cc9a89aeffbde1391e8bc9ae9724127904f01d",
+ 370716507988397359530778284103407727265240291588416995328
+ ],
+ [
+ "000000000000000003b0b73d9b3259c318cca48a6335b5d64545583f7f3773fa",
+ 340818253309165415058055171484606858815006633875327680512
+ ],
+ [
+ "00000000000000000198bcc5bd65fd0ccd1c7e3b49e0170ea80296cbfee05042",
+ 288495652867775987986282369150900282132304927019642126336
+ ],
+ [
+ "00000000000000000a60f379d3dc1413491f360809a97cbb02c81442c613dce7",
+ 259524902203633530447121351815377152077137395840706412544
+ ],
+ [
+ "0000000000000000038973a5f8ba8cdc7e371dcc8f4b24337ef695f24b962907",
+ 237834253647442358407456603145452341381064939329604812800
+ ],
+ [
+ "000000000000000004b8ec471974913d052a3af7dc2a8c6f01c2ac2f3d1f7b19",
+ 224600391397450328424792273873642383828872941895338164224
+ ],
+ [
+ "0000000000000000075d572eef1c4210adc7abf4e40986d7f0a80003853bfec4",
+ 187067719845325692996306936867878122094522982476155977728
+ ],
+ [
+ "0000000000000000074f9edbfc07648dc74392ba8248f0983ffea63431b3bc20",
+ 164898540577033087399552264895286015147022701908103004160
+ ],
+ [
+ "000000000000000003c4a4d9c62b3a7f4893afe14eef8a6a377229d23ad4b1ea",
+ 170169861298531990750482624090969781281789404909188153344
+ ],
+ [
+ "00000000000000000404b6939e6c35a5448386e5d58f318c82ce2fefb7d73e47",
+ 162900609378736249874251099581569547607832255884553093120
+ ],
+ [
+ "0000000000000000034656c96781091b5fbc799c881ea85b41cba0b88128eff7",
+ 161578008857017275969393492955354620126364423170461532160
+ ],
+ [
+ "0000000000000000045645e2acd740a88d2b3a09369e9f0f80d5376e4b6c5189",
+ 150883090635422687830679296233896712896447026244773478400
+ ],
+ [
+ "00000000000000000381e6a138308c6547d6fe3eb3437250ffefdebbf71eefd1",
+ 150899178845446426410002882396535253739927398750206558208
+ ],
+ [
+ "0000000000000000012100ddbb2102e65fb1ebbf104ead754a4110abffc4b8bc",
+ 138784382553152119468195441786396823230753870240366460928
+ ],
+ [
+ "0000000000000000046f56e59b9b1293b5e7c1587aa6d29c4f3f79b98cf22ee6",
+ 135262935280049154152065372885142255350817451144176992256
+ ],
+ [
+ "000000000000000001bd1c291e91f4476f93454d4542d2ed7e44fc86902c93bb",
+ 137505556928474480767543871928291413858290772017802117120
+ ],
+ [
+ "000000000000000001c37a483375ff6fd6ed7c5b79d80167b027a8fdb0721dcd",
+ 128713911367130082233924624261304605948946745676720504832
+ ],
+ [
+ "0000000000000000051804b4c2da5298c4573386bf1d4242bf0e26a49ec32e42",
+ 126333978716874242627475052620752087219210710628817698816
+ ],
+ [
+ "0000000000000000034bff7888f1f7294311f0199322f77c1457018c875bd9e1",
+ 126278605342839049377710151409810132688161986656629424128
+ ],
+ [
+ "00000000000000000506b43c9283ccbc40f583e0c734e4a8af2ce6a4262c6221",
+ 133533639774706835230353390473157702360903922769486413824
+ ],
+ [
+ "000000000000000003937068e19a0750a33978050f019d2b60f430e3da707db9",
+ 124022888639743237872084547350559836284832548627419234304
+ ],
+ [
+ "000000000000000002e2f6ec3c9eb965aa706c788da7dede201b6b4b8fae3971",
+ 122123731568103772089607259872577666017242529148853813248
+ ],
+ [
+ "000000000000000000b3076636b13562bb4315f895bcb324e0c962763c2196b1",
+ 119378259820331825692479928211144812308894309500762193920
+ ],
+ [
+ "00000000000000000025b8961d1d0cfba33b0205ec10b3ce541618e352b0bbd5",
+ 111759931157462873316041289986819959868258380300102402048
+ ],
+ [
+ "00000000000000000421d58b78b9f063a4b20e181d55c9c79082f9e4b8b30925",
+ 104283029085035157753191385936387396702868516379761311744
+ ],
+ [
+ "0000000000000000027fd968d41741f31c73c4a3b304472da0165245278e2ea3",
+ 106299667504289830835845558415962632664710558339861315584
+ ],
+ [
+ "00000000000000000364a23184b8a2c009d13172094421c22e4d9bc85dcf90a5",
+ 105881374043672627773432318187360570734220873198601240576
+ ],
+ [
+ "0000000000000000042a2ed4a504424060407825d774a54f2e148fa769ee72ff",
+ 95668727978371040303278646201741713440261619517174579200
+ ],
+ [
+ "0000000000000000025f769f13f2806fed19d9948b1a7ef19048177789afc5d3",
+ 94012390634764280055243391736606357298689315295029362688
+ ],
+ [
+ "000000000000000000b3ff31d54e9e83515ee18360c7dc59e30697d083c745ff",
+ 86923102180582917240747796162767475850640519180006195200
+ ],
+ [
+ "0000000000000000021ecdcb2368ce66c23efd8bd8ab6a88a8bb70571c6e67f0",
+ 84861566431029438820446406485131195674434646972185968640
+ ],
+ [
+ "000000000000000001972cb33b862b27c1dc3f3a723f7d1cfd69aebe0409126c",
+ 80022382513656536844370512820784980102919810105407963136
+ ],
+ [
+ "000000000000000000cb26d2b1018d80670ccc41d89c7da92175bd6b00f27a3e",
+ 68605739707508652902977299640495787127103841947617329152
+ ],
+ [
+ "00000000000000000276deb4022f66cacd929c690cd6b4f7e740836b614b21f4",
+ 63859343606086615291372321518809062931940920926127783936
+ ],
+ [
+ "000000000000000000587912ced677698c86eec8b1d70144dccb1c6b0bad0f17",
+ 61163258921643354765656928775243357859392914550528409600
+ ],
+ [
+ "0000000000000000009f989a246ac4221ebdced8ccebae9b8d5c83b69bb5e7c8",
+ 58509826700983959310706392369835644790490546910263246848
+ ],
+ [
+ "000000000000000000038bed8b89c4e82c13076dd64dc5f7a349c39d3921d607",
+ 56672777602924507578641088682504585686103825941044133888
+ ],
+ [
+ "00000000000000000122f47d580700a3a5b4b6cb46669a36e4fa974c720ab6cd",
+ 53958359841942568206719748916397287559357255547625668608
+ ],
+ [
+ "00000000000000000172ad9ea56a90bdfed0f364a902500e9ff4d74f000ced99",
+ 51764751112426770751506128647798102319231116027761786880
+ ],
+ [
+ "00000000000000000201d7429db233c7055e9699c5bfb57b167ca8d0c710dc71",
+ 51649140486907347007064544362790913467244253139882213376
+ ],
+ [
+ "000000000000000000c0549b2a8adbefbf6c909f61fdc4d6087c44a549cf8201",
+ 48144529712666433692552181910809237167694270386587828224
+ ],
+ [
+ "0000000000000000015b6789cdc5dc13766f58b38f16d5b35bf79ce4b040f7fd",
+ 45240046586752885057924289339576851866807485277820420096
+ ],
+ [
+ "0000000000000000013a31b29f845d97465bff53f901027f8ab4b1a2f59118a8",
+ 39718797393257298660757754408019939605415460564426031104
+ ],
+ [
+ "00000000000000000088cdeaa7389a7de9f09e3a28b3647630fea3bd1b107134",
+ 37880625861940376795251270290737354395669643839013912576
+ ],
+ [
+ "000000000000000001389446206ebcd378c32cd00b4920a8a1ba7b540ca7d699",
+ 38043004539854389433075372490391464304285496568268718080
+ ],
+ [
+ "000000000000000000f41e2b7f056b6edef47477d0d0f5833d5d4a047151f2dc",
+ 33509870757351677175294676059494700127350769223450230784
+ ],
+ [
+ "0000000000000000010e0373719b7538e713e47d8d7189826dce4264d85a79b8",
+ 31340207270661909233492904963194738468218672502370467840
+ ],
+ [
+ "00000000000000000053e2d10bd703ad5b7787614965711d6170b69b133aa366",
+ 29201223626342991605750065618903157022235193117232857088
+ ],
+ [
+ "000000000000000000cbeff0b533f8e1189cf09dfbebf57a8ebe349362811b80",
+ 30353962581764818649842367179120467226026534727449575424
+ ],
+ [
+ "000000000000000000d0ad638ad61e7c4c3113618b8b26b2044347c00c042278",
+ 29217311836366730185073651781541697865715565622665936896
+ ],
+ [
+ "000000000000000000a7bda943639876a2d7a8caf4cac45678fb237d59c28ba1",
+ 24433127148609864747615599184820261456796420809345204224
+ ],
+ [
+ "000000000000000000fb6c6a307c8363e923873499ba6299597769c10a438e61",
+ 23988269434232535193761088780698748366141469438183997440
+ ],
+ [
+ "0000000000000000006f408147ffbcaa0fb1dcf1f199c527ffdaf159d86e5cd9",
+ 22526487188587264742197108840494583820145762956159746048
+ ],
+ [
+ "000000000000000000e3be3cf7343d7792c0d47d3c39ddb9ceaf19961e9eeab4",
+ 18556440756915402760741928101946749165024073301499052032
+ ],
+ [
+ "000000000000000000b3fb09d6def197657e20f9c1d5e9680cfcac1e1f9aa269",
+ 19758940920085072387393228723348383373068660102939017216
+ ],
+ [
+ "000000000000000000bfe71f044145e1b42fdfb3a523ee2a215e80fa6afc2a98",
+ 20014481558369106100835306608979160026489460596213284864
+ ],
+ [
+ "000000000000000000cee3bff56ee49c0f96d1cbd17fa17dc6f84b3f48aed765",
+ 16946123176864917983795071264823963343174695083267063808
+ ],
+ [
+ "00000000000000000089ef13654974b8896b0b0909dd9ae8e350b8a8a7807ce3",
+ 14392961660539521116256653268419249019684881662910398464
+ ],
+ [
+ "0000000000000000003105a067417c318dab31e25ae1583fa2b27be226945fdd",
+ 13960450711994363030255127593764523087979983609872252928
+ ],
+ [
+ "000000000000000000720da39f66f29337b9a29223e1ce05fd5ee57bb72a9223",
+ 12101157559014734955774763823279522156034099347349045248
+ ],
+ [
+ "0000000000000000006a8957cbd52c2038861514f106f7f9f76392d5cb83fd4c",
+ 10356793971791534424976101420669664288187918308140384256
+ ],
+ [
+ "0000000000000000006b68e55432541794388c94fe9e805652038e7b3cac0681",
+ 9378292318569022964986206758839123913433917663832178688
+ ],
+ [
+ "00000000000000000001c9deea9f0302eadb1250df1ad53da802dfb40d47face",
+ 8964447668935855171055978546867850348456065181232922624
+ ],
+ [
+ "00000000000000000013aaa8778111530a626a3fe57e4e6f4a878c92669b04d1",
+ 8192878571041388924351625416816775770172128369752145920
+ ],
+ [
+ "0000000000000000002f67aa98789b98304a32e54bffbb34c8693eb0acac4c30",
+ 7786052052270684126234611299412205796254663675224260608
+ ],
+ [
+ "0000000000000000002e5f072398ee27b25b6cdcf69051bcdbbece417093c979",
+ 7678459224733657715202292429397298472913633233275453440
+ ],
+ [
+ "00000000000000000028d7447c20ade2053bbaf49e8a16eb5fb1bc74335d0d18",
+ 7021961458254440109762706424650140438182306270565892096
+ ],
+ [
+ "00000000000000000042d89446b9043387be2d4c09aa9e9524176c5754616510",
+ 6702918573828378664524678433037841287557455508299317248
+ ],
+ [
+ "00000000000000000018ec4d369bab2c13174834a02138decea7c85685d46bd6",
+ 6505870154073602347674948421782035713149324747260035072
+ ],
+ [
+ "0000000000000000000d4a6c2237c6c46b963b17f60d9c850c4915518deb6678",
+ 6259542822111302646229226565336702507884435252736688128
+ ],
+ [
+ "00000000000000000031adb986da21237ce06b57ae5390b7f0f890ab8e21b66a",
+ 5456617206587901877414813377199700077413780408546361344
+ ],
+ [
+ "000000000000000000031df41201cd3789559333cd9529f99834a805014c9b13",
+ 5309609141393698345581459330931267317315649121846034432
+ ],
+ [
+ "00000000000000000020c68bfc8de14bc9dd2d6cf45161a67e0c6455cf28cfd8",
+ 5026314587016750785722693470327208449351582469580652544
+ ],
+ [
+ "00000000000000000009dce52e227d46a6bdf38a8c1f2e88c6044893289c2bf0",
+ 5205879062684137510961952799929229129995569309608312832
+ ],
+ [
+ "0000000000000000002eca92f4e44dcf144115851689ace0ff4ce271792f16fe",
+ 4531442825108320403104334767545311437480985430866264064
+ ],
+ [
+ "00000000000000000000943de85f4495f053ff55f27d135edc61c27990c2eec5",
+ 4219470685603665866184576203153693664105230070242607104
+ ],
+ [
+ "0000000000000000001d9d48d93793aaa85b5f6d17c176d4ef905c7e7112b1cf",
+ 4007526641161212986792514236082843733160766044725313536
+ ],
+ [
+ "0000000000000000001877e616b546d1ba5cf9e8b8edd9eba480a4fbb9f02bce",
+ 3840827764407250199942201944063224491938810378873470976
+ ],
+ [
+ "00000000000000000025eb2c783f2f29d68ab4260f4b0248450c0038debc7ba4",
+ 3769176185135465353474348091454476000617158630021529600
+ ],
+ [
+ "0000000000000000000c61b8a7779dcc46e88ca343b9a3fcc6763917fe3b87e2",
+ 3616317728887026217259424694800679959591344645351669760
+ ],
+ [
+ "00000000000000000003dba9fedba6a0b92b640167eeda0d41485a3c85ac4ac6",
+ 3753318892370425056811838111019504329853891761930240000
+ ],
+ [
+ "0000000000000000001ac75bed7eb6169255893f99de28f24e3e0e57b6f7db7b",
+ 3752507758961706405692235065937346792777982719368888320
+ ],
+ [
+ "0000000000000000000e5796e9c5cdc8a8a2de84fd17287d7dfe89074de31766",
+ 4052052750044136275098507698196378011637603685579620352
+ ],
+ [
+ "00000000000000000015fe695e8d2e5ed3a7de81d3818ef43a444e1ee7b3ace2",
+ 4774638159061819979596346127394133648234752261950013440
+ ],
+ [
+ "00000000000000000015a08d0a60237487070fe0d956d5fb5fd9d21ad6d7b2d3",
+ 5279534360700703025330663904443631645337169341976674304
+ ],
+ [
+ "00000000000000000008f4f64baaa9b28d4476f2a000c459df492d5664320b12",
+ 4798269179035823348880781507454323228379569035237392384
+ ],
+ [
+ "00000000000000000028a69d9498c46b2b073752133e3e9e585965e7dab55065",
+ 4581847093576588582947343450056030606262879232408420352
+ ],
+ [
+ "00000000000000000014dbca1d9ea7256a3993253c033a50d8b3064a2cbd056b",
+ 4636475101776743072223960781733299832971578678999777280
+ ],
+ [
+ "00000000000000000019046cf62aa17f6e526636c71c09161c8e730b64d755ae",
+ 4447653474738502407900799312400854215681091162244907008
+ ],
+ [
+ "00000000000000000017e5c36734296b27065045f181e028c0d91cebb336d50c",
+ 4440088742263677654396177039706714734771352055402463232
+ ],
+ [
+ "0000000000000000002296c06935b34f3ed946d98781ff471a99101796e8611b",
+ 4442250303185290059812200289574302117357423179633524736
+ ],
+ [
+ "0000000000000000001ccf7aa37a7f07e4d709eef9c6c4abd0b808686b14c314",
+ 4226119056551884143559484765457720035561644907380604928
+ ],
+ [
+ "0000000000000000000de3e7a7711130dbac9fb0a14e5ad6ab72d080182f3321",
+ 4217024131862773934699503234743726606330326039165665280
+ ],
+ [
+ "0000000000000000000e6829c1245de98ce5a35c177a75f67e9c1678cb6e24aa",
+ 4243570847603252455305754966045185171099356397876281344
+ ],
+ [
+ "00000000000000000001b2505c11119fcf29be733ec379f686518bf1090a522a",
+ 4022508494445492072607020209303018350395259009223360512
+ ],
+ [
+ "0000000000000000000a4adf6c5192128535d4dcb56cfb5753755f8d392b26bf",
+ 4021030916290150529756716283937142188262386861422411776
+ ],
+ [
+ "0000000000000000000485ab94f5ea60203aacfc9740b3e42700d7e7012f76d7",
+ 3614033401827878015998272335407144409231622422786998272
+ ],
+ [
+ "0000000000000000000cbc6dfb3f2afbd6ed1427e30ed1f3167898ac4aa4c673",
+ 3638558860803927897868648370584956354584468626790678528
+ ],
+ [
+ "0000000000000000001d9865df58f5f300552699fefc09aa840ba25ac044a534",
+ 3397669776434136486181562425402160438435718857259745280
+ ],
+ [
+ "000000000000000000115eb6c10b7a98bf23a46002baec8fbbbb2cf0583439a6",
+ 2974300520630483197933400799376074857018768662277914624
+ ],
+ [
+ "000000000000000000113978c5b95531173923ba81ed4d1df3b09db37ae0f0cf",
+ 2990922178751847556822131306978557143801315583089180672
+ ],
+ [
+ "000000000000000000096b8d24db6471fb5871e9ae8bd1d7384fbee9c80a6052",
+ 2699909434228155498652331786772923585210445951064342528
+ ],
+ [
+ "00000000000000000016e0dd8fe86bf34feaa611b4c52180b6822b5ad31b68ff",
+ 2647377219375933524160418539145769508351933111739613184
+ ],
+ [
+ "00000000000000000011e20e47a868d12a2bf3de814ebd067e83514aa2725745",
+ 2502742632840755378666227277045667991877723059489079296
+ ],
+ [
+ "0000000000000000000c48f6bed594da7bb5e75731b4e78501670e834d426e87",
+ 2267299103571658911252368261549572946260211294613274624
+ ],
+ [
+ "0000000000000000000f7871dc40f51b1ecd6343a6d9fd614d0e2235a7d9e3fd",
+ 2112846149036891759953684644743283440459952687539027968
+ ],
+ [
+ "0000000000000000001558c0f33a360d105b52a749103eb2abd4a66a68d52664",
+ 2072520395859657486634608572838975759381606196813234176
+ ],
+ [
+ "0000000000000000000676463abf3771ea01e0f8c948d1c93658a1d82d95df5a",
+ 1969073848467738847181233556694484530967339635488849920
+ ],
+ [
+ "0000000000000000000e24396612da4ec125ee6c0b4507e854c5cfed1884cd30",
+ 2119459443945814095658556318611324621123895782295994368
+ ],
+ [
+ "00000000000000000002fb021eeb13e47021920faf6e5daa3c40bc552c4d248e",
+ 2078088717097888226752964612051624797686495299801972736
+ ],
+ [
+ "000000000000000000067b904af747b653ba448a79779f7846bf1ea5537b8a4d",
+ 2093644940525638357414324633411056914147713045789409280
+ ],
+ [
+ "000000000000000000080ae07ccf2f1b6d1d089f5dcbc1fac50a6b93d005f1e0",
+ 2082043540528505650049623783208955059537684253263265792
+ ],
+ [
+ "00000000000000000008f9ddf24dbec1459689fc399329e9738b2795860e4361",
+ 1953761695813422977307213550702116033770404430236090368
+ ],
+ [
+ "0000000000000000000aacba541ebb7b56b0831e4ae33faf20ff1e528bb9a657",
+ 1824503568004603261415443256727022530945994444270206976
+ ],
+ [
+ "00000000000000000010fe23dd08a4b6465c4850984bb538e9dfcb93995a23cc",
+ 1743137387349479903250289511035208906392689711805104128
+ ],
+ [
+ "0000000000000000001166c174a9d34b0743953e724162fe44388e38d078204c",
+ 1734095076719313606895363312975193263350078457161711616
+ ],
+ [
+ "00000000000000000006da92c61b6b63ea910be27cab5fd951137105314f2969",
+ 1740794600224838465872409004248364704712181251938713600
+ ],
+ [
+ "000000000000000000043f26353c41c2343a277ad72f115171fb49d3be52dbbc",
+ 1628687194130096895725758951785196783123433634364653568
+ ],
+ [
+ "0000000000000000000bc6800858a1b3be08fb26b55d4b989c95e06ad50a350c",
+ 1937788944419033539314165479165359776648584743473905664
+ ],
+ [
+ "0000000000000000000c799dc0e36302db7fbb471711f140dc308508ef19e343",
+ 1832085838499075985755083973639154607251969422303166464
+ ],
+ [
+ "0000000000000000000de98650125747f239134cf7e2b7362033e325a8003a14",
+ 1689336589076054705025375464973257095873115523033071616
+ ],
+ [
+ "0000000000000000001138f586983520b0de3645c0873164f4b214b90cf3aedc",
+ 1674005436900453533413418811078063286996924790657253376
+ ],
+ [
+ "0000000000000000000e87ecbff47d9ab75e78d92328d5951351f9702597dace",
+ 1780912820169571750977100152906426673601736600243404800
+ ],
+ [
+ "00000000000000000007c4dac98234149700771e9d1756956660b63cca88c36b",
+ 1963213226902041926479236780515292236058519345991516160
+ ],
+ [
+ "00000000000000000003030a3de58b57be352e2ca79016cefe19777e02ba0520",
+ 1707948812427463753688699391317898960128433823967870976
+ ],
+ [
+ "0000000000000000000cfd1300625612513c6cd1413245fcdaf1eeb766e33a93",
+ 1708005810991319658902509335026374895166200405337047040
+ ],
+ [
+ "0000000000000000000830b0a5ac4b78b5eb99209ebb4790be1fae1428c7f77c",
+ 1554226608711362053849117616927967595838003183165112320
+ ],
+ [
+ "0000000000000000000ed5cf2e86791b44abce69e178e58613e64ed47e1c02a3",
+ 1600203988720154928752887338080389143353359165034594304
+ ],
+ [
+ "0000000000000000000aac5c93f7945c60d82828990448cde97d3d7128830a6d",
+ 1590739304116800001454600275103718494518067345886281728
+ ],
+ [
+ "000000000000000000049a66ca322371799e1cb51d85c8937764ba6a2abb8ed9",
+ 1535456543183121267670627692621392373016562041515671552
+ ],
+ [
+ "0000000000000000000657c7aa925caa49d18e0c02cab9992be315012d8fab06",
+ 1554222224206450061140363005873469446988944215367483392
+ ],
+ [
+ "000000000000000000061250f1186194229157967d10a01a2b36ab19d4304da5",
+ 1395807138732878832030429199485686097922398375169228800
+ ],
+ [
+ "0000000000000000000d2e17e6d3179b4182518bd678f20bbda8b29e5e494d54",
+ 1397005570075490172423356221048513449998516239854469120
+ ],
+ [
+ "00000000000000000005e2dea23567cb4fe092a354e7d1b50b59571715de22f6",
+ 1348156339349342073285316259199804406349536350538039296
+ ],
+ [
+ "00000000000000000005e17383e25f65b531d50060b99ed66f673ea251949e4b",
+ 1605902383604108119230963505243149930846997646019657728
+ ],
+ [
+ "000000000000000000090386439b3e1c7dc56d2e450694e910b366895f05b9ef",
+ 1532070243889425565609149754863988745260019245813596160
+ ],
+ [
+ "000000000000000000046f183ba323cfceb2d11660376c59fb55e8521c4d32a5",
+ 1407282849589201081744164532792174352192736757496676352
+ ],
+ [
+ "00000000000000000006d248288fe5c88d55836f0ffcd9acae8333c824106a54",
+ 1443989924712404039437768281050676516514415159246061568
+ ],
+ [
+ "0000000000000000000a047b7c5b3b06db1bd4b858e757c7214d192cc491e2e9",
+ 1449469094350757594478113895488529861555105250349678592
+ ],
+ [
+ "0000000000000000000ea5abc8d23ce15f85afbdf574da6c82c67bef5df0d752",
+ 1308244191135472445492091830103155433365752488721907712
+ ],
+ [
+ "00000000000000000008ad1e1340f31a30d1fee7d0e3e56a0cf7dd571dc653aa",
+ 1294666840924668357381979598007221164113148875397660672
+ ],
+ [
+ "00000000000000000004f29390852281bae27d3662f648020bb47cced0d883b8",
+ 1257769770588612382309009370720465882998915202417688576
+ ],
+ [
+ "00000000000000000008aa78bb2eb233395c99d1276ab90a7fe728882b5c2907",
+ 1240994654795328278613867476210548386499304408689410048
+ ],
+ [
+ "0000000000000000000b842cbf7dfe7345b48deb00b76298f58ae0f58ce821ae",
+ 1256955714176619069383569918268642913356966847991250944
+ ],
+ [
+ "000000000000000000065952ab35814a9a021f9e5138623779c93c6c56ad6cf4",
+ 1232968087803106959787092839109270560155354027163385856
+ ],
+ [
+ "00000000000000000000b136fc67072ab98643c2346ff07c4076d94c35d9481c",
+ 1165190949371886336955396954992052935118810155482873856
+ ],
+ [
+ "00000000000000000005a2db60197fa9012b70f75e4745b362afc16a052d6ee6",
+ 1143226041264440196997713775641159917616401145294487552
+ ],
+ [
+ "000000000000000000035d5c18a83cb7e0ad0880a3b7936d277becaad9d5a00f",
+ 1308153578033957929511163201643527023818533820904243200
+ ],
+ [
+ "0000000000000000000d882df633c1ec8ef9ded1dbf09f02114cf9c999d1dde1",
+ 1076379879376199359324913638762382564863378102643851264
+ ],
+ [
+ "00000000000000000006248c28751a176336f5c070f901dc86df190c391d761d",
+ 1280876111474813957445809627925710317539675495922139136
+ ],
+ [
+ "00000000000000000001464428893b618817bff3128a6e17a2c043de53ca4673",
+ 1352521844740049480301990665795127943729248621043908608
+ ],
+ [
+ "00000000000000000009cbc816ab1d430e7a9cc24ffdb6702870112c84a9657c",
+ 1877009475827353279654828838027187714710153476809162752
+ ],
+ [
+ "0000000000000000000b964e653343c6ae97d73db7f526cbc3187ff7829b0c42",
+ 1971793703014811657512010614168169533666919325951328256
+ ],
+ [
+ "00000000000000000003c1a7a8f2e45be0b6ba0d2fac227ff2a43cf8b7eaec29",
+ 1859734526474102007161661283304481249417820354151186432
+ ],
+ [
+ "00000000000000000000019999808926fea81b376f1060e7411bc3a5d2853594",
+ 1733053026051896673114684085689466553557063777258569728
+ ],
+ [
+ "000000000000000000098d881262beb65d58d46176ae565dee9bb050f7b3d516",
+ 1530484514612921535942898756820491578183692559004467200
+ ],
+ [
+ "0000000000000000000019cb70a38e25e348efd12435797631ec48088e4abe63",
+ 1463986190114365453164631096931900700789347628299059200
+ ],
+ [
+ "0000000000000000000515117969caee77eadd03b30d8d50d044269172d844d8",
+ 1419099090327021431837841324664685500406654972106637312
+ ],
+ [
+ "0000000000000000000e1c751629fb7e8112c37eda91f9f6d8e223cd2ee50c05",
+ 1355224161267474319797749279050820351032592440315871232
+ ],
+ [
+ "000000000000000000029da63650d127e160033c93393da77302320bd8ee4958",
+ 1342441867947378242875139851503883739742681654295003136
+ ],
+ [
+ "00000000000000000006c0cd9b33f4d579e31ba1e6bbf4c5297324fa78fbf151",
+ 1244706868954148772026104835685647745369230477348569088
+ ],
+ [
+ "000000000000000000013712fc242ee6dd28476d0e9c931c75f83e6974c6bccc",
+ 1188998811044006745492934980917001185509005296607952896
+ ],
+ [
+ "00000000000000000001871f43a65da0527d3455aea40b0af27e0ef31b3d6b98",
+ 1207017664730659447571468211219560238858343288930304000
+ ],
+ [
+ "0000000000000000000a8c54b4dfa4ba0152a0c4d5bcbf999025cbcbf0efc039",
+ 1114247386799443053935571112778061457902663314832359424
+ ],
+ [
+ "00000000000000000004680d21d3c5e94dc03d197a3bb5653b1f9d7d5015a2a9",
+ 1110710552837102268873518195482888052995095958078357504
+ ],
+ [
+ "00000000000000000000135a8473d7d3a3b091c928246c65ce2a396dd2a5ca9a",
+ 1106174051754827146215413957762136710502083943464960000
+ ],
+ [
+ "00000000000000000002d5d3caf5cd22bd5cc211e4fb7e283c1713dabaa96df6",
+ 1011873581609325297224157601300783981224824207994519552
+ ],
+ [
+ "000000000000000000001fc83bc1767e7aa5c31bf9dc4aeb505247f6fca1d96d",
+ 1010078857598682948440603476326208385676686722831745024
+ ],
+ [
+ "00000000000000000004ddb634472f403757e89fefe4590b70a3841e7b307bf6",
+ 963971403944167623177113627223675088972581362965938176
+ ],
+ [
+ "000000000000000000041e39e07eefe69a84140b1020fe46b329a20ce5b74a77",
+ 978555728783092703397868198169350877225727913812295680
+ ],
+ [
+ "00000000000000000005f63eed68a9a3979f0db78aec853df62aae1234f43306",
+ 982035564181577583246111171756048347095528689197121536
+ ],
+ [
+ "000000000000000000076c23a2f567ea68fa523055dac2a0d94579ad277459d8",
+ 943064623022149056932209915691668660376403247938666496
+ ],
+ [
+ "00000000000000000001a5cc47cfd6abaf57dd33f891342f35bd8b8176f8867d",
+ 955133703543227653230735945040239725552721938878562304
+ ],
+ [
+ "00000000000000000002c1452eafc55a3a7b2e23b87dc94c53257be3b9fd2d92",
+ 904852201212495269232856372055468724544479235670016000
+ ],
+ [
+ "00000000000000000005c7686bd1dd938bf1e1849ae17d71efcd868e897a2ae2",
+ 862674725460762741916416231468109512880228678412271616
+ ],
+ [
+ "00000000000000000008b5ffa0ae1b604dd27bf4af84602ea53f7920320a3c96",
+ 901734818220068453308327912307284892863553131555848192
+ ],
+ [
+ "000000000000000000050c04aa3e3ca62420b6366e20ebea29ed3042320d2e4b",
+ 890244492347372894565410542152469475763018189902970880
+ ],
+ [
+ "0000000000000000000882e7306788b58cb5805c04f432203863a8e8c8290902",
+ 911713951399763858433822672345071673321763838959288320
+ ],
+ [
+ "000000000000000000018b957c6b644c23f532ab7d8fbbeca6fa77ad078a6b29",
+ 924766622522766152396299781586060796970310972500606976
+ ],
+ [
+ "0000000000000000000528876999d7bdfc25ad64e66ffe7f27a1e371bd9835c5",
+ 973529624652311728262165726029639579921131161797001216
+ ],
+ [
+ "00000000000000000001095f6deb27964f80c74f38217a32044c20265e0f40e3",
+ 956871428990014096800480126306339386063092842672160768
+ ],
+ [
+ "0000000000000000000073616e48a83e3e27b70ba27cd38c683d0014189b41b9",
+ 950899733299880027476699870079860653644778702301560832
+ ],
+ [
+ "000000000000000000016ca393ebd4f388b294134f5633a62d4268b3e4b544dc",
+ 870306687010904716955275873664553942808871958151692288
+ ],
+ [
+ "000000000000000000028a4784e2bd24e775437108c0e7a90469bff4a62895d2",
+ 841292956506611632223096322365470292302662385308532736
+ ],
+ [
+ "0000000000000000000136eb131bc275961ef87f4a06f56d849ef7de6067a83c",
+ 859664032087861081904916640712713969859737457373741056
+ ],
+ [
+ "0000000000000000000009f301f2215237cab791aedc296f102fd7b9dfbff456",
+ 757060771140682373435345150716700036747812369126653952
+ ],
+ [
+ "00000000000000000007ac57aa98595dd1daa3db89f17a817b445b083d696e0f",
+ 731886405437657570669286679473162061734238931073892352
+ ],
+ [
+ "00000000000000000006e6f83c247026057e769aace3815f8138941b256a3ad2",
+ 733349368576625804490408567990711061036914519549411328
+ ],
+ [
+ "00000000000000000001348162a93f4734709f6a142b19aeefd8714f46d0b8f9",
+ 729612308889970685728561745873455525355654300037021696
+ ],
+ [
+ "0000000000000000000428fc10bebcc825140bc83b01b8be32488bcd408e1389",
+ 787270009984312136754615316208945606764100494789967872
+ ],
+ [
+ "0000000000000000000152525a810f2033976f89ba434694c0fe5bf97730f625",
+ 762342638057996256581733267702136683580848909336969216
+ ],
+ [
+ "00000000000000000001fe51e048f4f42e4212098de90827f929403e17b71988",
+ 790751306884434347505776493480475792916920926107336704
+ ],
+ [
+ "000000000000000000064d0f3321a86b27d4883ab1ccedd12cf0941fb74f01a7",
+ 717191006474295341826748628480199835971598529354268672
+ ],
+ [
+ "000000000000000000053916f5f3b68319baf095c4205ad4edca517cd89b4a51",
+ 685105199528332699160504931662746558558072186305773568
+ ],
+ [
+ "00000000000000000006813d0260b4726b64e83025e904165ffaaf06d17227d7",
+ 688509036841676372057001313638142781710850853198364672
+ ],
+ [
+ "00000000000000000005c39a2670a916dc4075afaaf8fe30e495c13ba323e141",
+ 626181838016062686207286970262124176054603953970610176
+ ],
+ [
+ "00000000000000000005e07bf1518ede202f3a18b1a710e9700046755b3c7550",
+ 619023402996415923713925321951479821824329196375113728
+ ],
+ [
+ "00000000000000000005776a4242de5ebfc59d247a114188851e3d24fd173f61",
+ 575524729764536260159429050275345090310309676098519040
+ ],
+ [
+ "00000000000000000000eb00afd3cdc013b3033d0419037fa9c6f4243b5a7a79",
+ 562973353703138465897895804931977651737504527419441152
+ ],
+ [
+ "0000000000000000000060df647feb8b98f0b5e7ce312c35101bd5c6de001fcc",
+ 553442901526103647968289576137834770166328191306694656
+ ],
+ [
+ "000000000000000000044cb8431ecd498363f5ec16e05553b7f1f69b1a3a6a93",
+ 561592234655860762640193322765060764283929671166328832
+ ],
+ [
+ "00000000000000000001c951f76bd2d92beda3c1ab615d57558ecf654c900042",
+ 544090752548823200194704196893283275123549878964191232
+ ],
+ [
+ "000000000000000000036eb2f0d13b6e4fdb17da672479713a9e08d0f0875dbf",
+ 526200511006255617572972890856003254679941608705622016
+ ],
+ [
+ "000000000000000000033c59708025b67a8b8518d3760151eb4980e7a3a21e62",
+ 514982024438103606772841406080073066221062670505738240
+ ],
+ [
+ "0000000000000000000062afa9e3b9df5eee17c2edfc89f60778afe3abccb593",
+ 532311049351936122673982497141590033985123062667804672
+ ],
+ [
+ "00000000000000000003a3bbc3dac0c578948918c796426cda4c1958fc8454d3",
+ 500073246235691066104245617101534263137552502634840064
+ ],
+ [
+ "00000000000000000000dc8c2caadf5e8e8635adc7ff4d90dcfba264a3493e6a",
+ 515199788182065911307653755120147792390991404454641664
+ ],
+ [
+ "000000000000000000034e1a8f7c1efee7c36209a1556a377568d6368431dd17",
+ 514581572989474939373253596435908804673676944988962816
+ ],
+ [
+ "00000000000000000000967ad630a7aac2aeed84cc4c10ef1bce932cefdb374e",
+ 484696787509332636501824648976526249487752436350189568
+ ],
+ [
+ "00000000000000000002f985a10a40897d04888ac10c8ac75867a50b016eb6ef",
+ 497866378763321402697758053004132675777872044494946304
+ ],
+ [
+ "000000000000000000027ecc78c2da1cc5c0b0496706baa7e4d7c80812c10bf3",
+ 471981723264553781113452590931894587216745823226298368
+ ],
+ [
+ "000000000000000000047381dac259c4a8ce569c8498a2b9d11db6c080500343",
+ 470321457404545875398373204961928889706416683857477632
+ ],
+ [
+ "00000000000000000001df9394219ced52cb3789dc1c31408d6e01bec37c0b2a",
+ 441737408381628076124145537003663826407985955181953024
+ ],
+ [
+ "0000000000000000000353813d30e99afcc4579d469b57718f4521d570b3dce6",
+ 431604817530012926192239390058441836232711374861500416
+ ],
+ [
+ "000000000000000000009af85a7f58a31a21d77ad6dbeb8f52a11de6ec716b79",
+ 416823189970048174077527321660349349771911273121841152
+ ],
+ [
+ "00000000000000000000cc85f351633ed7b58e8b827ad0176798c4905084e95c",
+ 396710004437100288117208210992507862855406329465405440
+ ],
+ [
+ "0000000000000000000125ab365abaa653d41dd6acb9ecddf9bb9a944d1e0bfd",
+ 400552292241643231889165698417718970914081776120889344
+ ],
+ [
+ "0000000000000000000078f4e50a09391670d061deea0b3b87c3fa60cfaaf782",
+ 374406027949793378682501776760424667692437142927048704
+ ],
+ [
+ "00000000000000000001595b16989798f8997d89ac778ccc7d0d6c7e88ab4ec9",
+ 368311566122123513513592411007997767500471904222838784
+ ],
+ [
+ "000000000000000000026ab1b5e445f9320cf2ec3dc6720b501877d4e0b40eff",
+ 383255420363831995852225088422521761376453814474768384
+ ],
+ [
+ "0000000000000000000268358721568565da476fdf07250d18ee210bc2f874c0",
+ 357069695527774208266769667274744118513278471102267392
+ ],
+ [
+ "000000000000000000005b3388af9acbfd8c00b5b3ec888a75085011cc69cbb2",
+ 329879919066870090376508314646890389215599502072741888
+ ],
+ [
+ "000000000000000000010e2bc83838d1b9479b88341e4d0033dff524d16d5ef5",
+ 339749439623765677783137798322223448447336014535458816
+ ],
+ [
+ "0000000000000000000350156217f3a450f8994e7054b631db2f0e07f43f268d",
+ 321145985282180614537323094086577881890135649195917312
+ ],
+ [
+ "00000000000000000000c1d14709a7659153a2299083637a01475b7865926b0b",
+ 324317443835188673869825090173572216042789022814175232
+ ],
+ [
+ "000000000000000000023aeab989430385cf0c085e9e25580d1813ea3daee028",
+ 312072983117630369221114618645075196904111619969122304
+ ],
+ [
+ "00000000000000000002bf1e60049e942ac34b728911adda77d704cc8401e84b",
+ 305996059309608474887223697110640892108382252455428096
+ ],
+ [
+ "000000000000000000021342b77cc83903ed85341a53b9ec571fb7b0a503124c",
+ 324234138241860812403487480138107387910668634659225600
+ ],
+ [
+ "00000000000000000000a2a87e6a371a8d2e63eb61a051333c7a3251b717c723",
+ 319495949933634025142671133910441198360944101354897408
+ ],
+ [
+ "0000000000000000000132f5df736574a143d1e41e1a0cdb9c7d1656a906124c",
+ 322033116776040472608672730780036665683066800249503744
+ ],
+ [
+ "0000000000000000000112beeccb1e3ba4e55ee2987685cc397cdd21e4c65fe2",
+ 322192420454509541026756932426802740532209296896688128
+ ],
+ [
+ "000000000000000000016e792fc3bd650030e9acd074a910c7905bbe9bb79899",
+ 339134147434449367654574047007649893296060866934865920
+ ],
+ [
+ "00000000000000000001a481e800d3a60641bfc442db64a2c2d9d11d3a298705",
+ 328583567114557579488061646200271046177164689907122176
+ ],
+ [
+ "00000000000000000001b39765b103785831b8f5a23d9fb42187226d1faf82f3",
+ 297348354121521522320212493955458645481078099598639104
+ ],
+ [
+ "0000000000000000000314bc1981218b70bf539ae13ac1e41eefc1ad7a605049",
+ 310338180674118587457206844748640968959780028040609792
+ ],
+ [
+ "0000000000000000000073036581ef712215c5f9aebfeb7d2fba84a2f71dd69f",
+ 301319254070149585548971905645948786445483268317904896
+ ],
+ [
+ "000000000000000000028030eb24a8d0bd042bd589839edc932c876dda457c9c",
+ 290914823913990887674279873321841567628552684544458752
+ ],
+ [
+ "00000000000000000000a98d2c6a1875258ba9611e24b421c88325ec5155e2c8",
+ 304956931645466202912380877194579614881406884417372160
+ ],
+ [
+ "00000000000000000001924bab37e9d87715e84aa7bcd0b52405f893dfe7005f",
+ 292880543616200952099263829421844968289989913814761472
+ ],
+ [
+ "000000000000000000007c7decd9c85fe0c5273691cf361b548855d91176fdab",
+ 281789207690496729853016065226361096453821041746116608
+ ],
+ [
+ "00000000000000000001b4ceba765aa1ecec00c4e3710b7d5115a90060c48cf6",
+ 265227471136262937983931908702020177275080014169112576
+ ],
+ [
+ "0000000000000000000026f3661ace0cda87b922d13d35280c9ac61c6ad364cf",
+ 263561359269705708657179707992723614632672251070119936
+ ],
+ [
+ "00000000000000000001a2a4d658523967c398ae9c4adda72c02f4072ec398f7",
+ 259426771137696584301581483600969249970065617906040832
+ ],
+ [
+ "0000000000000000000087427d61da6ce8b57e2726a9c62be8637cc906bad7e5",
+ 248423125310232216230425940495448355115076101789974528
+ ],
+ [
+ "000000000000000000002428ab473fa8570115d4a000e637814b86248d35d370",
+ 245573197117436955539928755071651603226747033331171328
+ ],
+ [
+ "000000000000000000006d0b2fecfc61125fe5a7c6387fdca048c4b3cd5ffa84",
+ 244083926948996765466279200227113710829717638069878784
+ ],
+ [
+ "00000000000000000001651e40ff05e359a13e1740d9c21f400abdc5f1b287c9",
+ 249381870384321288544767557745710236775970393538166784
+ ],
+ [
+ "0000000000000000000268d24974a0f3d8ec73bfc6b0a470c6bc891951b1e3b2",
+ 236140665550103308105842173161300712617887644698804224
+ ],
+ [
+ "00000000000000000001a3361e40ce264e71620908f8e8cd555b4182f7813bb7",
+ 243826702660826526552675351696555645018258193942315008
+ ],
+ [
+ "000000000000000000009e2b057f15897a5bbe33a9f5a4ca96d426368554f34e",
+ 240389250809824242889060284970006947356027440601235456
+ ],
+ [
+ "000000000000000000023d9b58d8793462a5d4fd778b8fe1ba9d8cd8f26816eb",
+ 236991259503029893604236717733941589335327397438816256
+ ],
+ [
+ "00000000000000000002065cd1b5c268e8e375d1a176da03d22479d0c02e2da6",
+ 221874948068116364721256005509157074063026087146815488
+ ],
+ [
+ "00000000000000000000ca2f64794d814b198eb237974573222e4521545582d5",
+ 218766334085513534214236767869969540080217918627905536
+ ],
+ [
+ "000000000000000000008765cffc1a859242108b85e6415ae144ec918a05787e",
+ 226329605058700956815940836879276304706937369537806336
+ ],
+ [
+ "000000000000000000006ebc27176d134d4885a125cd7e3a13783a6c9bcd12c9",
+ 221600185760298154972633712760606412855330771828736000
+ ],
+ [
+ "00000000000000000000a4d531b0328083c2f19a7f6c31e22e18cb73ff7a4d2c",
+ 212309419851785605121612888279029001699378008653037568
+ ],
+ [
+ "0000000000000000000108312d8ba9c66bf9c806b9c7bf2e923d282508941005",
+ 213268164925874677435954505529290883360272300401229824
+ ],
+ [
+ "000000000000000000009d9d77c8ae464658da8ad3ba1b2d07eca1937b9cef39",
+ 230505115236555346453248764446346725294094368813088768
+ ],
+ [
+ "000000000000000000011a2a7498613bba536d6f0f5649aca4e6a484d6312211",
+ 213504928191122283708703502472190921209456561473191936
+ ],
+ [
+ "00000000000000000000a4b1cede1eba74d67df322ca415468f277aa7b508f47",
+ 211248369663083369602997013090476980227107801626836992
+ ],
+ [
+ "000000000000000000016e02290e770058422eef8df58d85c037a2d21ca9afb4",
+ 208285905844213629387798143934561074546265226362224640
+ ],
+ [
+ "000000000000000000010ee7b1a4f44b8cf580f3fe692f362c43741f10b81c8b",
+ 207862070369387667541519075333073352470565005924761600
+ ],
+ [
+ "00000000000000000000b34c3fbb0151989a03d8fe589af6a5621528e6786c29",
+ 198173776015521112096746848576997112333265829097373696
+ ],
+ [
+ "000000000000000000019f01622aa9ccac55b505788f3310454eb40145b7d73a",
+ 189398920184986370975851924841368549083251610109345792
+ ],
+ [
+ "000000000000000000010919a63dfd4bb86037ad9f2c26cb8e4f3b18d345cb4a",
+ 178729958232470779672965025562539683039763302545620992
+ ],
+ [
+ "00000000000000000001d4b4cbfc67c0904765c9b6d6dd00ac3a72fd4efaac90",
+ 183753139359977093002831090332585547778320742695829504
+ ],
+ [
+ "0000000000000000000095fdebd0bf892913da1bf6f109a2fbddd7b4d13bb8fb",
+ 172847414142213895427195194110856643885648174060142592
+ ],
+ [
+ "000000000000000000014b809628248ecd176df547224411fedafd577041929d",
+ 177049231349540241317030788004915957567158980121198592
+ ],
+ [
+ "00000000000000000001c83578eeac30ea13eacc3668da7e37c8aed04992ec95",
+ 180571450295507717349901668451762199644529777549770752
+ ],
+ [
+ "0000000000000000000144a6109dda22b210490144247f34903e23ea98281065",
+ 181918954805126809840485465867526612588652547354394624
+ ],
+ [
+ "00000000000000000001d39c254559a52d78a36cb5373269db169857d9aa490e",
+ 181841495218348271985820670571392649588610782929616896
+ ],
+ [
+ "0000000000000000000159186ff01cb830839027afce1c072816ad1caf6735c2",
+ 184058593202179251712735660462623250929428832597311488
+ ],
+ [
+ "00000000000000000000fd7f37b8fb43c92b11c9d2809f115d22a6b5dbeb7836",
+ 190300666695219538076383598383154495706379320488361984
+ ],
+ [
+ "00000000000000000001209d8bd5b083d0eec0a0cfb72a9f922a5f46e3b4744f",
+ 214194756963942469886095641713233006794734161633476608
+ ]
+]
\ No newline at end of file
diff --git a/electrum/chains/mainnet/fallback_lnnodes.json b/electrum/chains/mainnet/fallback_lnnodes.json
new file mode 100644
index 000000000000..1f8fce8d766b
--- /dev/null
+++ b/electrum/chains/mainnet/fallback_lnnodes.json
@@ -0,0 +1,106 @@
+{
+ "0294ac3e099def03c12a37e30fe5364b1223fd60069869142ef96580c8439c2e0a": {
+ "host": "8.210.134.135",
+ "port": 26658
+ },
+ "03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f": {
+ "host": "3.33.236.230",
+ "port": 9735
+ },
+ "03cde60a6323f7122d5178255766e38114b4722ede08f7c9e0c5df9b912cc201d6": {
+ "host": "34.65.85.39",
+ "port": 9745
+ },
+ "027100442c3b79f606f80f322d98d499eefcb060599efc5d4ecb00209c2cb54190": {
+ "host": "3.230.33.224",
+ "port": 9735
+ },
+ "033d8656219478701227199cbd6f670335c8d408a92ae88b962c49d4dc0e83e025": {
+ "host": "34.65.85.39",
+ "port": 9735
+ },
+ "02f1a8c87607f415c8f22c00593002775941dea48869ce23096af27b0cfdcc0b69": {
+ "host": "52.13.118.208",
+ "port": 9735
+ },
+ "034ea80f8b148c750463546bd999bf7321a0e6dfc60aaf84bd0400a2e8d376c0d5": {
+ "host": "213.174.156.79",
+ "port": 9735
+ },
+ "033e9ce4e8f0e68f7db49ffb6b9eecc10605f3f3fcb3c630545887749ab515b9c7": {
+ "host": "213.174.156.72",
+ "port": 9735
+ },
+ "035e4ff418fc8b5554c5d9eea66396c227bd429a3251c8cbc711002ba215bfc226": {
+ "host": "170.75.163.209",
+ "port": 9735
+ },
+ "037659a0ac8eb3b8d0a720114efc861d3a940382dcfa1403746b4f8f6b2e8810ba": {
+ "host": "34.78.139.195",
+ "port": 9735
+ },
+ "03a93b87bf9f052b8e862d51ebbac4ce5e97b5f4137563cd5128548d7f5978dda9": {
+ "host": "134.209.139.244",
+ "port": 9735
+ },
+ "0288be11d147e1525f7f234f304b094d6627d2c70f3313d7ba3696887b261c4447": {
+ "host": "18.219.93.203",
+ "port": 9735
+ },
+ "0322d0e43b3d92d30ed187f4e101a9a9605c3ee5fc9721e6dac3ce3d7732fbb13e": {
+ "host": "164.92.106.32",
+ "port": 9735
+ },
+ "02c197ffa4c2aa4105dd4c4b7279ba1b9061b22910ebbfa759b0001bed9ee48a16": {
+ "host": "18.181.210.139",
+ "port": 9735
+ },
+ "03c8e5f583585cac1de2b7503a6ccd3c12ba477cfd139cd4905be504c2f48e86bd": {
+ "host": "34.73.189.183",
+ "port": 9735
+ },
+ "021c97a90a411ff2b10dc2a8e32de2f29d2fa49d41bfbb52bd416e460db0747d0d": {
+ "host": "54.184.88.251",
+ "port": 9735
+ },
+ "037f990e61acee8a7697966afd29dd88f3b1f8a7b14d625c4f8742bd952003a590": {
+ "host": "185.5.53.91",
+ "port": 9735
+ },
+ "0326e692c455dd554c709bbb470b0ca7e0bb04152f777d1445fd0bf3709a2833a3": {
+ "host": "57.129.59.146",
+ "port": 9735
+ },
+ "02e4971e61a3f55718ae31e2eed19aaf2e32caf3eb5ef5ff03e01aa3ada8907e78": {
+ "host": "52.38.27.190",
+ "port": 9735
+ },
+ "0391904d140fdf88d19423513945a5fcc49c606521b65a85f6d6fe46ebdd1c7665": {
+ "host": "5.75.184.195",
+ "port": 35933
+ },
+ "026165850492521f4ac8abd9bd8088123446d126f648ca35e60f88177dc149ceb2": {
+ "host": "45.86.229.190",
+ "port": 9735
+ },
+ "027ce055380348d7812d2ae7745701c9f93e70c1adeb2657f053f91df4f2843c71": {
+ "host": "157.90.112.145",
+ "port": 9735
+ },
+ "029efe15ef5f0fcc2fdd6b910405e78056b28c9b64e1feff5f13b8dce307e67cad": {
+ "host": "103.126.161.206",
+ "port": 9742
+ },
+ "03abf6f44c355dec0d5aa155bdbdd6e0c8fefe318eff402de65c6eb2e1be55dc3e": {
+ "host": "3.132.230.42",
+ "port": 9735
+ },
+ "03271338633d2d37b285dae4df40b413d8c6c791fbee7797bc5dc70812196d7d5c": {
+ "host": "63.35.146.37",
+ "port": 9735
+ },
+ "03037dc08e9ac63b82581f79b662a4d0ceca8a8ca162b1af3551595b8f2d97b70a": {
+ "host": "34.68.41.206",
+ "port": 9735
+ }
+}
\ No newline at end of file
diff --git a/electrum/chains/mainnet/servers.json b/electrum/chains/mainnet/servers.json
new file mode 100644
index 000000000000..3295de262fb0
--- /dev/null
+++ b/electrum/chains/mainnet/servers.json
@@ -0,0 +1,475 @@
+{
+ "5.9.83.108": {
+ "pruning": "-",
+ "s": "50002",
+ "version": "1.6.0"
+ },
+ "104.248.139.211": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "128.0.190.26": {
+ "pruning": "-",
+ "s": "50002",
+ "version": "1.4.2"
+ },
+ "142.93.6.38": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "157.245.172.236": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "159.65.53.177": {
+ "pruning": "-",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "167.172.42.31": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "188.230.155.0": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "22mgr2fndslabzvx4sj7ialugn2jv3cfqjb3dnj67a6vnrkp7g4l37ad.onion": {
+ "pruning": "-",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "2AZZARITA.hopto.org": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "2electrumx.hopto.me": {
+ "pruning": "-",
+ "s": "56022",
+ "t": "56021",
+ "version": "1.4.2"
+ },
+ "2ex.digitaleveryware.com": {
+ "pruning": "-",
+ "s": "50002",
+ "version": "1.4.2"
+ },
+ "37.205.9.165": {
+ "pruning": "-",
+ "s": "50002",
+ "version": "1.4.2"
+ },
+ "68.183.188.105": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "73.92.198.54": {
+ "pruning": "-",
+ "s": "50002",
+ "version": "1.4.2"
+ },
+ "89.248.168.53": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "E-X.not.fyi": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4"
+ },
+ "VPS.hsmiths.com": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4"
+ },
+ "alviss.coinjoined.com": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "assuredly.not.fyi": {
+ "pruning": "-",
+ "s": "50002",
+ "version": "1.4.2"
+ },
+ "bejqtnc64qttdempkczylydg7l3ordwugbdar7yqbndck53ukx7wnwad.onion": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.5"
+ },
+ "bitcoin.aranguren.org": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "bitcoin.lu.ke": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "bitcoins.sk": {
+ "pruning": "-",
+ "s": "56002",
+ "t": "56001",
+ "version": "1.4.2"
+ },
+ "blackie.c3-soft.com": {
+ "pruning": "-",
+ "s": "57002",
+ "t": "57001",
+ "version": "1.4.5"
+ },
+ "blkhub.net": {
+ "pruning": "-",
+ "s": "50002",
+ "version": "1.4.2"
+ },
+ "blockstream.info": {
+ "pruning": "-",
+ "s": "700",
+ "t": "110",
+ "version": "1.4"
+ },
+ "btc.electroncash.dk": {
+ "pruning": "-",
+ "s": "60002",
+ "t": "60001",
+ "version": "1.4.5"
+ },
+ "btc.litepay.ch": {
+ "pruning": "-",
+ "s": "50002",
+ "version": "1.4.2"
+ },
+ "btc.ocf.sh": {
+ "pruning": "-",
+ "s": "50002",
+ "version": "1.4.2"
+ },
+ "btce.iiiiiii.biz": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "de.poiuty.com": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50004",
+ "version": "1.4.5"
+ },
+ "e.keff.org": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4"
+ },
+ "e2.keff.org": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4"
+ },
+ "eai.coincited.net": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "ecdsa.net": {
+ "pruning": "-",
+ "s": "110",
+ "t": "50001",
+ "version": "1.4"
+ },
+ "egyh5mutxwcvwhlvjubf6wytwoq5xxvfb2522ocx77puc6ihmffrh6id.onion": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "electrum.bitaroo.net": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "electrum.blockstream.info": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4"
+ },
+ "electrum.dcn.io": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "electrum.diynodes.com": {
+ "pruning": "-",
+ "s": "50022",
+ "version": "1.4"
+ },
+ "electrum.emzy.de": {
+ "pruning": "-",
+ "s": "50002",
+ "version": "1.4.2"
+ },
+ "electrum.hodlister.co": {
+ "pruning": "-",
+ "s": "50002",
+ "version": "1.4"
+ },
+ "electrum.hsmiths.com": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4"
+ },
+ "electrum.jhoenicke.de": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.6"
+ },
+ "electrum.pabu.io": {
+ "pruning": "-",
+ "s": "50002",
+ "version": "1.4.2"
+ },
+ "electrum.qtornado.com": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4"
+ },
+ "electrum3.hodlister.co": {
+ "pruning": "-",
+ "s": "50002",
+ "version": "1.4"
+ },
+ "electrum5.hodlister.co": {
+ "pruning": "-",
+ "s": "50002",
+ "version": "1.4"
+ },
+ "electrumx.alexridevski.net": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "electrumx.erbium.eu": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4"
+ },
+ "electrumx.schulzemic.net": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "elx.bitske.com": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "ex.btcmp.com": {
+ "pruning": "-",
+ "s": "50002",
+ "version": "1.4.2"
+ },
+ "ex03.axalgo.com": {
+ "pruning": "-",
+ "s": "50002",
+ "version": "1.4.2"
+ },
+ "explorerzydxu5ecjrkwceayqybizmpjjznk5izmitf2modhcusuqlid.onion": {
+ "pruning": "-",
+ "t": "110",
+ "version": "1.4"
+ },
+ "exs.dyshek.org": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "fortress.qtornado.com": {
+ "pruning": "-",
+ "s": "443",
+ "version": "1.5"
+ },
+ "fulcrum.grey.pw": {
+ "pruning": "-",
+ "s": "51002",
+ "t": "51001",
+ "version": "1.4.5"
+ },
+ "fulcrum.sethforprivacy.com": {
+ "pruning": "-",
+ "s": "50002",
+ "version": "1.4"
+ },
+ "gall.pro": {
+ "pruning": "-",
+ "s": "50002",
+ "version": "1.4.2"
+ },
+ "guichet.centure.cc": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "hodlers.beer": {
+ "pruning": "-",
+ "s": "50002",
+ "version": "1.4.2"
+ },
+ "horsey.cryptocowboys.net": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "kareoke.qoppa.org": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "kittycp2gatrqhlwpmbczk5rblw62enrpo2rzwtkfrrr27hq435d4vid.onion": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "node.degga.net": {
+ "pruning": "-",
+ "s": "50002",
+ "version": "1.4.2"
+ },
+ "node1.btccuracao.com": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "nuzzg3pku3xbctgamzq3pf7ztakkiidnmmier64arqwh3ajdddovatad.onion": {
+ "pruning": "-",
+ "s": "50002",
+ "version": "1.4.2"
+ },
+ "qly7g5n5t3f3h23xvbp44vs6vpmayurno4basuu5rcvrupli7y2jmgid.onion": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "rzspa374ob3hlyjptkdgz6a62wim2mpanuw6m3shlwn2cxg2smy3p7yd.onion": {
+ "pruning": "-",
+ "s": "50004",
+ "t": "50003",
+ "version": "1.4.2"
+ },
+ "skbxmit.coinjoined.com": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "smmalis37.ddns.net": {
+ "pruning": "-",
+ "s": "50002",
+ "version": "1.4.2"
+ },
+ "stavver.dyshek.org": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "tardis.bauerj.eu": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4"
+ },
+ "ty6cgwaf2pbc244gijtmpfvte3wwfp32wgz57eltjkgtsel2q7jufjyd.onion": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "udfpzbte2hommnvag5f3qlouqkhvp3xybhlus2yvfeqdwlhjroe4bbyd.onion": {
+ "pruning": "-",
+ "s": "60002",
+ "t": "60001",
+ "version": "1.4.5"
+ },
+ "v7gtzf7nua6hdmb2wtqaqioqmesdb4xrlly4zwr7bvayxv2bpg665pqd.onion": {
+ "pruning": "-",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "v7o2hkemnt677k3jxcbosmjjxw3p5khjyu7jwv7orfy6rwtkizbshwqd.onion": {
+ "pruning": "-",
+ "t": "57001",
+ "version": "1.4.5"
+ },
+ "venmrle3xuwkgkd42wg7f735l6cghst3sdfa3w3ryib2rochfhld6lid.onion": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "vmd71287.contaboserver.net": {
+ "pruning": "-",
+ "s": "50002",
+ "version": "1.4.2"
+ },
+ "vmd84592.contaboserver.net": {
+ "pruning": "-",
+ "s": "50002",
+ "version": "1.4.2"
+ },
+ "wsw6tua3xl24gsmi264zaep6seppjyrkyucpsmuxnjzyt3f3j6swshad.onion": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ },
+ "xtrum.com": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4.2"
+ }
+}
diff --git a/electrum/chains/mutinynet/fallback_lnnodes.json b/electrum/chains/mutinynet/fallback_lnnodes.json
new file mode 100644
index 000000000000..90919b283a57
--- /dev/null
+++ b/electrum/chains/mutinynet/fallback_lnnodes.json
@@ -0,0 +1,18 @@
+{
+ "02465ed5be53d04fde66c9418ff14a5f2267723810176c9212b722e542dc1afb1b": {
+ "host": "45.79.52.207",
+ "port": 9735
+ },
+ "032ae843e4d7d177f151d021ac8044b0636ec72b1ce3ffcde5c04748db2517ab03": {
+ "host": "45.79.201.241",
+ "port": 9735
+ },
+ "0220566172d9e324b41ec6f74ca44d377d3faf72ddb310fd263e6d5bcde4882492": {
+ "host": "185.90.61.24",
+ "port": 9735
+ },
+ "035a4e767bb1be29ed20219b40f07d9be03656a5f83485821878963c05290a877c": {
+ "host": "54.158.203.78",
+ "port": 9746
+ }
+}
\ No newline at end of file
diff --git a/electrum/chains/mutinynet/servers.json b/electrum/chains/mutinynet/servers.json
new file mode 100644
index 000000000000..a109bc53819b
--- /dev/null
+++ b/electrum/chains/mutinynet/servers.json
@@ -0,0 +1,7 @@
+{
+ "5.9.83.108": {
+ "pruning": "-",
+ "s": "51234",
+ "version": "1.4"
+ }
+}
\ No newline at end of file
diff --git a/electrum/chains/regtest/servers.json b/electrum/chains/regtest/servers.json
new file mode 100644
index 000000000000..08ae1c9c1faf
--- /dev/null
+++ b/electrum/chains/regtest/servers.json
@@ -0,0 +1,8 @@
+{
+ "127.0.0.1": {
+ "pruning": "-",
+ "s": "51002",
+ "t": "51001",
+ "version": "1.4"
+ }
+}
diff --git a/electrum/chains/signet/checkpoints.json b/electrum/chains/signet/checkpoints.json
new file mode 100644
index 000000000000..132fe7ae0d89
--- /dev/null
+++ b/electrum/chains/signet/checkpoints.json
@@ -0,0 +1,578 @@
+[
+ [
+ "000000c0a3841a6ae64c45864ae25314b40fd522bfb299a4b6bd5ef288cae74d",
+ 0
+ ],
+ [
+ "000001299957ea59afccbd3f1c4719a466350aea3fda78d419dfde37f9823420",
+ 0
+ ],
+ [
+ "000000647c13ba87cb352cbbb464d47f15f1733332c3b910888ff36d858961ce",
+ 0
+ ],
+ [
+ "00000096b144d9ab24117213adf6e9e52afb606629f30f10d084ebb4bb80e3c3",
+ 0
+ ],
+ [
+ "0000000d41b251c50f6e7e54855b68f4a57004164b9d6014f84c111cd60c2680",
+ 0
+ ],
+ [
+ "00000102ea9911f35f8ee910b2bf56946b8163766b8d71b6aae668b2bd1f1501",
+ 0
+ ],
+ [
+ "00000108f2a56ca7ccdb6bef0372eead4ba9eab39b2795be544fc8e90b93c01c",
+ 0
+ ],
+ [
+ "0000014938ef851ea035d13c02ccdca9e8c3499d33466daab04a1a0a798bbbfc",
+ 0
+ ],
+ [
+ "000000739a899ef6a642dc478966abc174a789a88280d83579cc057b95d19aef",
+ 0
+ ],
+ [
+ "000000d5237e345c02c467e9d4eda4a207a35af96473daca024c6cd363c7db83",
+ 0
+ ],
+ [
+ "00000000bbf300f7561f228cf55f51237beb76f23a4370363ea41e930ebf63a1",
+ 0
+ ],
+ [
+ "0000007dcebd40572e399d89055df55c19ea5a67d2a29155e0dd2f35313aeec0",
+ 0
+ ],
+ [
+ "0000013d4b88ae84d4b29f64e2ae2466dd0130dfb2f2ce8d9496a469a543fe55",
+ 0
+ ],
+ [
+ "0000014ce59f8d976fe17e7cc8d8e5698890995b98073c78bd7c493ba72a2e62",
+ 0
+ ],
+ [
+ "000000933087962ab5518c60cc4ca4bdbfeb6dbc7fd3d3d420bc8ef46dc53202",
+ 0
+ ],
+ [
+ "000000b622bcdf74d742db0b4d9653034d2549a4e9642962091cad05b1082645",
+ 0
+ ],
+ [
+ "000000dfd2d4606684bbeb504959ce225c2ad84d0343c35cdad78d59dc86395c",
+ 0
+ ],
+ [
+ "00000120bd3e4e26b6b1418860ead049560f7beec69a4a496cbc780e3f34d8bb",
+ 0
+ ],
+ [
+ "000000db40817d098b504b8a524b2df500b6f6c9b9abd954c4116dda7620e0dc",
+ 0
+ ],
+ [
+ "000000dbc1b9e4c662ac78c95fc46e689b54d0c2c284dde285dd54dd9b24b123",
+ 0
+ ],
+ [
+ "00000041d0bd45cf26d854ff7188e773b87466fd33bfdbf7397ecdade8724318",
+ 0
+ ],
+ [
+ "0000002327e4edf0f436fbb87392a68c2bfeb6284d215704a017e741fdc1154a",
+ 0
+ ],
+ [
+ "000000f198a4c393044bd9b3c4b340bebe811ce721b5d6788f931282f5eac3fd",
+ 0
+ ],
+ [
+ "00000106876fcfbb809096bf96df6dfe1de43e408bee2f88500954d38bf3cb72",
+ 0
+ ],
+ [
+ "0000003e477f554425d1b570dc23535f5365e0424a1b4a4b81dc373db1421430",
+ 0
+ ],
+ [
+ "0000002cd0191c4d8cc646fe277a6d9be51d5d5e116ed75491291bf18ab7fe68",
+ 0
+ ],
+ [
+ "000000940edf438dc29af2fe8e77d831eb3e2434f372d68598da354310289d4a",
+ 0
+ ],
+ [
+ "00000103744843bf10a3f0ddd264fae8c7199fadd7187883975c3dc9976b6a61",
+ 0
+ ],
+ [
+ "00000141abbf269a681f83cf6b243e505d5e29f483f18f366f3cb9419bace51d",
+ 0
+ ],
+ [
+ "000000d0a1eb4b089678e0ebd64f211e0b9cb68f2f1fd1b56ab1d5eca03d167d",
+ 0
+ ],
+ [
+ "0000005a587db11708cfd476c5a8308e9ddb210dbda90c64ccae2092fe218367",
+ 0
+ ],
+ [
+ "0000011dd3d96c272e17eb43ddf33651238871aa525c9b7885e88ee4c5898337",
+ 0
+ ],
+ [
+ "000000055aee09f4d9a965638fea3e45130a3f601363d0614e618481bad3b519",
+ 0
+ ],
+ [
+ "00000038bcef9e43f8a75438a925403aeb1b77ed9013c1c719b57c77e8137d2e",
+ 0
+ ],
+ [
+ "0000003ce9a4b46fbb431c198aedf59ef322c0791f03e3bf153548fea29dc375",
+ 0
+ ],
+ [
+ "000000715d041a78f798c6ef7cce244a0a7751acc839446bfe52d84087696162",
+ 0
+ ],
+ [
+ "0000005bcfde62a40e676a73f6fd481b4d5d524891b7b5a7e5874da0af640885",
+ 0
+ ],
+ [
+ "0000003a0ebac1a5bf4ec8e7d9f94d672f9ce217eecbcfe1837b0b4d68bb7efb",
+ 0
+ ],
+ [
+ "00000002b09a09076aaa8cdeddf164c14bd69c0d3c7c700cc2a1e70d5782fba3",
+ 0
+ ],
+ [
+ "0000010e5f6260be27d95a9c6b77b3427f675ac6a79ffa848d2065122e0a49fb",
+ 0
+ ],
+ [
+ "00000027c6cadacbf945fde93ca6fa9b2c404216b7b54d46d12acd6bd2084403",
+ 0
+ ],
+ [
+ "0000011d5a1230c91fcb07e1ffed7c74a77824f49632d7a85984afc5fdb84210",
+ 0
+ ],
+ [
+ "000001420feee79c0518790bae4fcf3d11379f46b7c92568d5d49ce061c14aaf",
+ 0
+ ],
+ [
+ "0000012beab6aa1841d9ca386986bf4a1227e4a1c9c4f6bada49dfc5c45a7b4c",
+ 0
+ ],
+ [
+ "000001425fa8c62dfd856ae0fee3b36add930a5826778f62c54c5e7a089cb2cd",
+ 0
+ ],
+ [
+ "00000047aa330a7bfd668afa6fceec2d71b0a01d4c3e940f01fea16cf8a5495a",
+ 0
+ ],
+ [
+ "000001138c3f76d7b699e3f610946b35ab8d0fb670d7b277e848cf93e2963478",
+ 0
+ ],
+ [
+ "0000011579b1ffe43056593c6d2541b20ecad58654a70b926b80f02bf7e3afa2",
+ 0
+ ],
+ [
+ "0000002a0af0dd33f39c900d2b08ca7b53ec7ad5cf8cdac6c0a594c824528554",
+ 0
+ ],
+ [
+ "000000174c981f1f0f785a066db5ab640fd81db7131d3bc6c0ae5f1c881a5869",
+ 0
+ ],
+ [
+ "00000010b2e80adbf6ebef457bfe030f028fd7c054eb91967067ecbe32391e6a",
+ 0
+ ],
+ [
+ "00000056b45f9fba1d07427704b8929aa5a6273864c3c28fbf0561a970927eba",
+ 0
+ ],
+ [
+ "0000015fee8d0e568e47beb80e459c1e55f0939bcfb67c8d71d6aea4bbef07da",
+ 0
+ ],
+ [
+ "00000026f4c92697d195eb8380852e8e2f72f00bd237ed7b6a34ab27d46df667",
+ 0
+ ],
+ [
+ "0000006997e8fb35db20f6cba346e8a4f5ad3e53b2816b04b5f15ee7d300c507",
+ 0
+ ],
+ [
+ "00000153eeef8fe2c11990a87b680c86595bd1eefe62e912bd4d2d0181dd275c",
+ 0
+ ],
+ [
+ "0000011255d8a68c30b8dc8f9c0d5bd83484dbd1a67c19f9773e942b869184d4",
+ 0
+ ],
+ [
+ "00000127cd6eb4b0bb794bafce0990f25ad88da0338aaa643b08f73679ee7c40",
+ 0
+ ],
+ [
+ "000001351829786299ce95aa0e4fff6ca6fb0579176476ebb23cd9c5b18dc38b",
+ 0
+ ],
+ [
+ "0000003cd9b6c744cb5c8fdc012c200c9fbb72c8e82a535286ad29e9daae0b7b",
+ 0
+ ],
+ [
+ "000001195e3dc8e195350793514679ce2b2951c8e69e2ab759627b7d4dae0174",
+ 0
+ ],
+ [
+ "000001469586fbf43e84517bd8aebd65f556a3f8caae21676fde3d8a37d548c5",
+ 0
+ ],
+ [
+ "000000f0298f92b43e87c972155819d6e6fa80a99a309a7a63a447748fdbcae2",
+ 0
+ ],
+ [
+ "000000793ff1190b63052655624f2e3e771257ee5e4a95d0fe8bf8900119f4a7",
+ 0
+ ],
+ [
+ "0000002b535d083651a6a816526ab64e18a3473117e11bf9fdb37409fd2b8476",
+ 0
+ ],
+ [
+ "00000056df0365d1e9ac806b3124919922d8d2fa528ec230562b384ef957e049",
+ 0
+ ],
+ [
+ "000000e4d73bc1ff88044907fdabcd18fb4feeae8d89214e00a835497eff9d44",
+ 0
+ ],
+ [
+ "000001164ada2f23afdc6e7eb7a549d0240ec00795c97a6b7b0da459ba144236",
+ 0
+ ],
+ [
+ "0000003a02c60020ea02296f487408b092ea86fcace4b71fe65ddf8e9d1227af",
+ 0
+ ],
+ [
+ "00000011c1bc4de2a5565d4f6b52e2d20830ae03cc33da840752a9b082f22970",
+ 0
+ ],
+ [
+ "000000590d9132c7ec8cf2fdc08d841aa30f1a6c98baaa41bb2f2ced082ffc56",
+ 0
+ ],
+ [
+ "000000aad06d12ca4f1614a04529b9b0db229edd3b999c8f2387bf4499e7e823",
+ 0
+ ],
+ [
+ "000000f21e5a641ff87de60c94f9f95c28291989fa52980862a96b1da9420c20",
+ 0
+ ],
+ [
+ "000000389253c73a42abfce6f70dd8b4b47c1e94ff70f3650136315f1c7c3d51",
+ 0
+ ],
+ [
+ "00000052d02504f8361173e62831ffb1ed06502e39bfe56e7b2e8534527acf54",
+ 0
+ ],
+ [
+ "0000006e903dbc8f9c6a155c31ac0eabc64a0f538ec1956c12bf0296365ea10f",
+ 0
+ ],
+ [
+ "0000012d6d51f5edeb1bc181aa56058ba399c56a233196a609d17ea9fe1260f4",
+ 0
+ ],
+ [
+ "00000078fe34ae6dcfebd6c54750fc5b23c9ccfccb85410a67dc873950089470",
+ 0
+ ],
+ [
+ "0000008ba099907877add94606ade4d8c4fce72e78e4c9a7211511e191cb3090",
+ 0
+ ],
+ [
+ "000000ba16aeb089d8272fda74e376bc74063e202b41c1d910466b28d0e22aa1",
+ 0
+ ],
+ [
+ "000000e9305b2194cc90a8f36ac85d6e335f9c40eebe31a63111b1a2cd62ef60",
+ 0
+ ],
+ [
+ "000000b20594e8e7b7c883ebcd4fb5038ac6e5490d8089643e63fcbbed54159c",
+ 0
+ ],
+ [
+ "000000ebaf4db2459b51830938659a587f72bd56fc713f8c15bbd877eb6e4cdf",
+ 0
+ ],
+ [
+ "00000121189334c3922651f449fb752026ac9d3c83ca36f1b379629acb8a4bc8",
+ 0
+ ],
+ [
+ "0000011331e953cf2ebd0231d00cf8ce3b0925d4642d32359922d87dde28e6a5",
+ 0
+ ],
+ [
+ "000001088db354ec53ca636d01718cbd5948d240d37b2bde660cf46e64b7bd13",
+ 0
+ ],
+ [
+ "000000a163f70345b7c90e1053b582aa415f8211ced4f9693edf493032d8b969",
+ 0
+ ],
+ [
+ "000000116824a8e4a6c2d6d0e32b8df377f701091d6a4ff2457790052fec8c2f",
+ 0
+ ],
+ [
+ "00000071aed5672329b4ceaa9fe0dc56a73a9aaa6ae368e83fd49d2fc613d182",
+ 0
+ ],
+ [
+ "0000006980fba2dab31f8a62e96484952512fefd1ef1a7d1ac6b2b70520298d4",
+ 0
+ ],
+ [
+ "0000010a5c600b597167962263ee3f34a9844f11cf62eae87254140fd1ca4c4b",
+ 0
+ ],
+ [
+ "00000032c5455af9f5ecbd52120902a2476b525bdb1bdc95304c72a75298576c",
+ 0
+ ],
+ [
+ "00000145fda2984b2467e98dca41b15a210817ce8507ed6470bfabaf6e1d58a0",
+ 0
+ ],
+ [
+ "000000fc300d0bb620acb44dcb5fd4a75746de32a250724e26346798781b762c",
+ 0
+ ],
+ [
+ "0000013835a5ae1a46e514eca524b79ea5acbf16bbd16d4913d5711fbfd0b43a",
+ 0
+ ],
+ [
+ "000001105408d97b67a382a8682793c6fa17e8a9e14431ff46931852d98809a9",
+ 0
+ ],
+ [
+ "000000b246b4b35ffc56055ff91448ba0c395a7697369680460aab62560c71c8",
+ 0
+ ],
+ [
+ "0000001af62d9a537a82ca68c50cab0726fe74a209358fa7ae02aa12fb68dc03",
+ 0
+ ],
+ [
+ "000000357adc640f81f63c32cc2bf924953a9087465478eace40ad368bbb610b",
+ 0
+ ],
+ [
+ "00000074d93559ca744568b640e31497e54448f5e81eb458d5e6309a866fbdc0",
+ 0
+ ],
+ [
+ "0000000e5f9f2f28a8e84fba06965b939eb33d10f367b285e0b765f73432293e",
+ 0
+ ],
+ [
+ "000000bc8fbad304cc9f7a2129b3ada77205993d6797aaa3c412d47378cfe1d3",
+ 0
+ ],
+ [
+ "0000001bfaddc170487792c89290b630bf2987cf465bceee6228c98339d5b51c",
+ 0
+ ],
+ [
+ "0000005d328174d05468a64f3a1c9b876607b229803896be461d4bf71a604ad6",
+ 0
+ ],
+ [
+ "000000767328d1681b20d958b8e3ace547f35d780e3fbca8f6917896d7a040fb",
+ 0
+ ],
+ [
+ "0000013f4af09699526f35b5d991b6acc392ad3b71d97955acbc061896f613da",
+ 0
+ ],
+ [
+ "000000a5bee294beb00596090e6f193fe72463b22feda343c108ed89f81cb395",
+ 0
+ ],
+ [
+ "0000008c2e331069893d102b28aae90710ece9fd4926d005de976f97e44b66e7",
+ 0
+ ],
+ [
+ "0000015205ced605eded9c5d62e3cf8ed5229f9a72e7beb4bc8d63655b36abf3",
+ 0
+ ],
+ [
+ "0000013ae7e94521be9703ffe12b1214d47fcf56fb1268a6652db590ec06bbc2",
+ 0
+ ],
+ [
+ "000000d18fb8d06bda4847c4a629e17295a2fa1092e14ffbff0d97a2eae72993",
+ 0
+ ],
+ [
+ "0000010f9a0dae27ef69bef205f2e6d900e120b29e684a2a08286abc95579840",
+ 0
+ ],
+ [
+ "0000006e29c2511c1ad31fe21d0064594c557e5fac6f73f7961f2b53cf36b983",
+ 0
+ ],
+ [
+ "000000dffd144d2e1eac01a786558c04cb65a8ed0002397db7b43011c223174e",
+ 0
+ ],
+ [
+ "0000008365805832101e989fbf04bdca8c364b8afcb794b2166db74a4a92cf8f",
+ 0
+ ],
+ [
+ "0000004a9fde1918b96cb079d4b48a98098231d34f162e8e898919e0573ada05",
+ 0
+ ],
+ [
+ "00000063f777d9004ebcf1685a07dc2e36c3ed40758d5ed5b7dc82ca2165f8da",
+ 0
+ ],
+ [
+ "00000084364ae6e59f9223629293d017a1718f587e08b45478e79ca3681ba813",
+ 0
+ ],
+ [
+ "000000e826cbbeb467e73577566db478f432295952489e154a3de4db0012dc91",
+ 0
+ ],
+ [
+ "000000d2f92728dbccd1782fde6a555a89f47ba4102d3bc0e0d07ac6c99cb468",
+ 0
+ ],
+ [
+ "00000066d082b9414191fbf2b18746513e10f403eb3d6e837f4a007375f3bcbf",
+ 0
+ ],
+ [
+ "0000007b872779a56424e1eae91113b3f43e7da689c5d2e9e86c5b8bbe2f3315",
+ 0
+ ],
+ [
+ "000000057927903d3c9ea07cc6344cfa7299541470c7c97caf24b9d9e39bfb38",
+ 0
+ ],
+ [
+ "0000000842d0fa799de1397043352af7fe7da168314c9a0a173fa13304d396a8",
+ 0
+ ],
+ [
+ "0000000b66f892759e13adc4249d1200a4c9bba92d56830816d5804cd2254d29",
+ 0
+ ],
+ [
+ "0000000bd582d47ad9e2457a02af92c63fd1be6bd7aec7f71d7aec5e145e2bed",
+ 0
+ ],
+ [
+ "0000000111a8861cafcd74da47754738fef19072b5da46ee663a52e11f042d6b",
+ 0
+ ],
+ [
+ "0000000c210b5e309f204bea96516a1d2b147b4855f2afc501445463ca55726f",
+ 0
+ ],
+ [
+ "0000000f0532cadd9795ad911a851d75ce9e198bf671a0e1299a39b86f9ff0ea",
+ 0
+ ],
+ [
+ "00000006a125ea1b6fecbdbc49a106a8ab3c8eb6e3ed6f49daf33818ba635d59",
+ 0
+ ],
+ [
+ "000000021f43833bc6dc117c0a99abd8428b30e042865f2bdfe89258ab5ce450",
+ 0
+ ],
+ [
+ "0000000bb33efcb77b81fb0ee743523a02a93f662d2ea8fb083662bbb47e02ca",
+ 0
+ ],
+ [
+ "0000000c62a5d5e42d512c83c542bd936bb5f735cb416d555476be79185f6206",
+ 0
+ ],
+ [
+ "0000000d896f71fab78384d2c78c8e6a2367315d282464be8233b0bc21d877bd",
+ 0
+ ],
+ [
+ "00000000f5d2c35c12db500f5d250ad24e8f363d2937d1c487b5f86731adb552",
+ 0
+ ],
+ [
+ "00000000d72f5a316078cd8747e8b8027baa8f661649bf1a8ed722e4f4231d3a",
+ 0
+ ],
+ [
+ "000000079f91871f027e6e3fc37dd638759f1e7b8498fd71c7ff397132b20f86",
+ 0
+ ],
+ [
+ "00000004cafb2f9b4aa75f426cfcd4bd2774cf14b3e7d89a8b1945e5d8de8195",
+ 0
+ ],
+ [
+ "000000034c3e13346b9843061f780d74ce2c18a264610b26f26576f7631c7aa1",
+ 0
+ ],
+ [
+ "000000110f528afa3f436cdb42d15287c378559a85d1e1e110a11ffd5309d4e7",
+ 0
+ ],
+ [
+ "0000000019963af20c504e4e78fc7c5bbc5fd52efaa19b4ee778f6f213072c93",
+ 0
+ ],
+ [
+ "000000046df3b5b7b222fe177bfc016e92d760bf31d8670be3e5b2318a57c27f",
+ 0
+ ],
+ [
+ "000000003da79540fc14abc1d14ee5b4cd635e4e903cf116862abc21e82db694",
+ 0
+ ],
+ [
+ "000000102cd648540dabef56265de9ebfec7c43bd3192d873613f06e9bac372e",
+ 0
+ ]
+]
\ No newline at end of file
diff --git a/electrum/chains/signet/fallback_lnnodes.json b/electrum/chains/signet/fallback_lnnodes.json
new file mode 100644
index 000000000000..f0ef95a3a53e
--- /dev/null
+++ b/electrum/chains/signet/fallback_lnnodes.json
@@ -0,0 +1,50 @@
+{
+ "02357a375a846279fc1e8413f5e182652a125e5f6a4f4653bffabebb8177a6d8aa": {
+ "host": "34.68.95.152",
+ "port": 9735
+ },
+ "0305061295fa30847df41ae6ee809b560e78d65c2a7337a41c725ea3920b65e08a": {
+ "host": "34.124.125.201",
+ "port": 9735
+ },
+ "027554f8d4d99a43cf1b49d274f698ee5045273cd377206eba62ea308b4386a4fa": {
+ "host": "35.247.14.99",
+ "port": 9735
+ },
+ "0244bb7ba2392ab2d493ad04ad4afcd482ca44a2bfe5b42bcc830bfe00e5b08082": {
+ "host": "34.138.100.228",
+ "port": 9735
+ },
+ "03adf6efe5346d455172c750a655b07fb85be4f50f5b555f9f91a853a6b448c3bf": {
+ "host": "34.74.81.232",
+ "port": 9735
+ },
+ "03ea42c9408a73dabdcb5655e2923956d132fbb25cb71e7c00a29e10c73e937e64": {
+ "host": "34.138.237.159",
+ "port": 9735
+ },
+ "024d899b60d5de58e8d66af042445323a48b6962d6c667c033802421dc49abc232": {
+ "host": "34.75.211.29",
+ "port": 9735
+ },
+ "02e8430ba207ce87bd2d4ab36497b9eac10e6d5d86f9fda8aa270c48877e0a8259": {
+ "host": "34.73.252.102",
+ "port": 9735
+ },
+ "0265ed138065b84d6b9448f9e0a2fd4ceb63fef08efe1dfc949a63d5d43110e4c0": {
+ "host": "175.45.182.145",
+ "port": 39735
+ },
+ "0307238136c48cd35084c4efadc486143a7e8a7acd8ff8ac053fdab4efabc551c4": {
+ "host": "104.244.73.68",
+ "port": 9735
+ },
+ "020ee56ff81d12d17d5d3eea5306a8982a5763522ca73e0e220ce282030543c90c": {
+ "host": "84.247.50.180",
+ "port": 44149
+ },
+ "0271cf3881e6eadad960f47125434342e57e65b98a78afa99f9b4191c02dd7ab3b": {
+ "host": "signet-eclair.wakiyamap.dev",
+ "port": 9735
+ }
+}
\ No newline at end of file
diff --git a/electrum/chains/signet/servers.json b/electrum/chains/signet/servers.json
new file mode 100644
index 000000000000..5e99a1762d32
--- /dev/null
+++ b/electrum/chains/signet/servers.json
@@ -0,0 +1,18 @@
+{
+ "signet-electrumx.wakiyamap.dev": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.4"
+ },
+ "electrum.emzy.de": {
+ "pruning": "-",
+ "s": "53002",
+ "version": "1.4"
+ },
+ "mempool.space": {
+ "pruning": "-",
+ "s": "60602",
+ "version": "1.4"
+ }
+}
diff --git a/electrum/chains/testnet/checkpoints.json b/electrum/chains/testnet/checkpoints.json
new file mode 100644
index 000000000000..aff1838a9b20
--- /dev/null
+++ b/electrum/chains/testnet/checkpoints.json
@@ -0,0 +1,9602 @@
+[
+ [
+ "00000000864b744c5025331036aa4a16e9ed1cbb362908c625272150fa059b29",
+ 0
+ ],
+ [
+ "000000002e9ccffc999166ccf8d72129e1b2e9c754f6c90ad2f77cab0d9fb4c7",
+ 0
+ ],
+ [
+ "0000000009b9f0436a9c733e2c9a9d9c8fe3475d383bdc1beb7bfa995f90be70",
+ 0
+ ],
+ [
+ "000000000a9c9c79f246042b9e2819822287f2be7cd6487aecf7afab6a88bed5",
+ 0
+ ],
+ [
+ "000000003a7002e1247b0008cba36cd46f57cd7ce56ac9d9dc5644265064df09",
+ 0
+ ],
+ [
+ "00000000061e01e82afff6e7aaea4eb841b78cc0eed3af11f6706b14471fa9c8",
+ 0
+ ],
+ [
+ "000000003911e011ae2459e44d4581ac69ba703fb26e1421529bd326c538f12d",
+ 0
+ ],
+ [
+ "000000000a5984d6c73396fe40de392935f5fc2a8e48eedf38034ce0a3178a60",
+ 0
+ ],
+ [
+ "000000000786bdc642fa54c0a791d58b732ed5676516fffaeca04492be97c243",
+ 0
+ ],
+ [
+ "000000001359c49f9618f3ee69afbd1b3196f1832acc47557d42256fcc6b7f48",
+ 0
+ ],
+ [
+ "00000000270dde98d582af35dff5aed02087dad8529dc5c808c67573d6dabaf4",
+ 0
+ ],
+ [
+ "00000000425c160908c215c4adf998771a2d1c472051bc58320696f3a5eb0644",
+ 0
+ ],
+ [
+ "0000000006a5976471986377805d4a148d8822bb7f458138c83f167d197817c9",
+ 0
+ ],
+ [
+ "000000000318394ea17038ef369f3cccc79b3d7dfda957af6c8cd4a471ffa814",
+ 0
+ ],
+ [
+ "000000000ad4f9d0b8e86871478cc849f7bc42fb108ebec50e4a795afc284926",
+ 0
+ ],
+ [
+ "000000000207e63e68f2a7a4c067135883d726fd65e3620142fb9bdf50cce1f6",
+ 0
+ ],
+ [
+ "00000000003b426d2c12ee66b2eedb4dcc05d5e158685b222240d31e43687762",
+ 0
+ ],
+ [
+ "00000000017cf6ee86e3d483f9a978ded72be1fa5af37d287a71c5dfb87cdd83",
+ 0
+ ],
+ [
+ "00000000004b1d9fe16fc0c72cfa0395c98a3e460cd2affb8640e28bca295a4a",
+ 0
+ ],
+ [
+ "0000000046d191b09f7726e4f8bfaffed6c30734afbf1f95e6bddbe0b07d9e88",
+ 0
+ ],
+ [
+ "0000000082cec8200e9ea055c2991bf74560eb7e7140691ea53e7828dbdc9553",
+ 0
+ ],
+ [
+ "000000003775b96d6b362d4804afe2d9c3cf3cbb46a45c3ccc377c94e83edd23",
+ 0
+ ],
+ [
+ "00000000037835a92404acb2f18768a49d4f93685ead30aad6bb3b073f411e02",
+ 0
+ ],
+ [
+ "0000000006cf75d17706d1f62e6b08e6ba5facfde38a8920b7d808a6b6781ff2",
+ 0
+ ],
+ [
+ "0000000003dff257cdae43703fcd0ca91fda0970f5fc04258b4608fb1942a6f6",
+ 0
+ ],
+ [
+ "0000000000532d97d18867658e08c789f627535652382147e33bf8626d4131bc",
+ 0
+ ],
+ [
+ "000000000266dfb79bb11dedd0ae748505863ab3ab731269cd71a2c2fbd159b3",
+ 0
+ ],
+ [
+ "00000000349ff0119d5c0dd8ffad8bf41cd6126a88416148b81fa4dcaebc42e1",
+ 0
+ ],
+ [
+ "000000003c61939b4799eeea4335218d30de9b1071605126d719dce0f0d14810",
+ 0
+ ],
+ [
+ "000000003d9284570ed648d2b12ad24046ac8b9abcf05c4e9813ea110490cf73",
+ 0
+ ],
+ [
+ "0000000001360b66e6dc0ccfbd75356034e721ae55c3d5c71a58be5d281c252b",
+ 0
+ ],
+ [
+ "000000000c114f42504916bfb2ee26ed8307b3f7f74226c1cfe1f5302ec23d26",
+ 0
+ ],
+ [
+ "0000000007acac3fcf97b4ca81821263b704364adaa2736fce0a0722bfed4f8d",
+ 0
+ ],
+ [
+ "00000000059768ef7731d27f9c2be48c6e16d7cb56680625f08ff25ead504280",
+ 0
+ ],
+ [
+ "000000000351c8908f1f52518ce4bd251b896ca3fbccb69a2607db6624bafcfc",
+ 0
+ ],
+ [
+ "0000000068d7ccae048e212e9e2ecb4d944f583b4490df4fbf654b4915597052",
+ 0
+ ],
+ [
+ "000000000e2aaa36417187233ff55325473bd5b7a164b358da60c96d1920fd77",
+ 0
+ ],
+ [
+ "000000001eb11ef6dbe0647bc87a8d218f6e59c2b9690f17edcf0dbd39cd0308",
+ 0
+ ],
+ [
+ "00000000022e7855e24cc3fff67ce093242434a8ffa45882333a0f08a40aad9c",
+ 0
+ ],
+ [
+ "000000000210130ff4e3186258c09a8463c1e196f5c5432b4c7b6954e907bf63",
+ 0
+ ],
+ [
+ "0000000000e01372ede322bf88ee5ed8a46dd4fd8df832eca16180263fc8b1ef",
+ 0
+ ],
+ [
+ "00000000a0701896e26d5d884834b267512e0af52c92edc4bccf1c5c803d3c4f",
+ 0
+ ],
+ [
+ "00000000869fc8d9ac1588f3e5bdfd60253e9824083800b7794010e0e9c6b6fe",
+ 0
+ ],
+ [
+ "000000001d43b3165ec30736f28f0761600b092686f861db23ec38f2d92b0ec6",
+ 0
+ ],
+ [
+ "000000000ef4092da8c2056e5933de0e1530194c3ad941a9b393fbb26f98862e",
+ 0
+ ],
+ [
+ "0000000001e3fed39f70023909f962bea146b03bc8e94e5d19d7da93123f4f64",
+ 0
+ ],
+ [
+ "0000000000b4b8c877bbe3cde97649845290bb78999ecff4621b9bf2ab16aa2e",
+ 0
+ ],
+ [
+ "00000000006095ba3b4742883a0ec427a3fd685ffb65b987ea77ebfedea7da82",
+ 0
+ ],
+ [
+ "000000000168f0a76a6068a34fc042553aff4aa63b906028f28c2a4c327328e1",
+ 0
+ ],
+ [
+ "0000000000af10f3079b4989ac4ff0baaecab38220510cdae9672d6922e93919",
+ 0
+ ],
+ [
+ "0000000000312791ada0f6a4c5eaf2a1cd57cd06f5970a8ab49923817b862c35",
+ 0
+ ],
+ [
+ "000000000055f3d4f45c4d199d9c230cb2cfeb68c8e934cfd061bd616358655a",
+ 0
+ ],
+ [
+ "000000000036b6129bb5a786bfdd75cb4b932f7dcae9da469d3ba35096f1e821",
+ 0
+ ],
+ [
+ "00000000002fbccf271c13e486673251ecd7951ecc12ee73c4390e0ff09e9b59",
+ 0
+ ],
+ [
+ "0000000000314e297a81bf002fc40eb391d8883ea45ee4e782385aa0fdba6452",
+ 0
+ ],
+ [
+ "00000000d3c473819ec3b3c268f7b555df22772e407bc8f246a47cfc579ec61f",
+ 0
+ ],
+ [
+ "0000000075a438fda6bdb391263d0a2a6e8e68edd9dd8f70fe5734eab9351eb8",
+ 0
+ ],
+ [
+ "0000000017ebae0a2bec50008b4a4ea8839798cbd9ff228e76aba087d0ff1736",
+ 0
+ ],
+ [
+ "000000000800466ba31c0bbc12b125f16d05ed27788de045e25d6f093817d29c",
+ 0
+ ],
+ [
+ "00000000002163c41f2264f202e611aeb9ba6c0a3ee95cd8e5e7e571edc64edf",
+ 0
+ ],
+ [
+ "0000000000de9882d417786fce8c755cfaad17f40cda744d4badedfe5e414e31",
+ 0
+ ],
+ [
+ "00000000002af352cf41f60a5ebf033bf7e4967c0597cee706ba877b795aefb4",
+ 0
+ ],
+ [
+ "0000000000009ca0030f1dd0b09cc628f2d4d278c87b20781a1b136dc395debf",
+ 0
+ ],
+ [
+ "00000000ffd27370a76d06a0da0e3805f47e35e2cf584d73d2c5ecaa2e525642",
+ 0
+ ],
+ [
+ "00000000720da6910aa75099baa020cb8db37e1dc19cdff66152225b7609c23a",
+ 0
+ ],
+ [
+ "000000000a5c2cc704bce5e8527ce91bac7430c659624ecd86e6a1bb9b697962",
+ 0
+ ],
+ [
+ "00000000084273545134e9a06483c8fab00c2b0628056bb1967f310c74a971bc",
+ 0
+ ],
+ [
+ "0000000002f66f4da52804647b1c3e1f89d17bdb05e9cd4ebbd922007c773f21",
+ 0
+ ],
+ [
+ "00000000c46146c9d0a67a354b3f82947e52670a3bded6d8513ab34a68ae18bd",
+ 0
+ ],
+ [
+ "000000002f61c429d7dbe7bde75796086efe574998766806138710a2d6001eba",
+ 0
+ ],
+ [
+ "0000000001daf3e3e78a57df2c2d2ddd14093d10515925e75c818bec3bbd30c2",
+ 0
+ ],
+ [
+ "0000000002e133a7427a9aac6ceca969b27507c14111a45512cdf8f52a436de0",
+ 0
+ ],
+ [
+ "0000000000f7c4374d458666740de1d0e8c55229a209ced7c38e38708781487c",
+ 0
+ ],
+ [
+ "000000000035bb9ea329ba30b83eeb4ea6f57c2fe703b97f9b879f21e22643e0",
+ 0
+ ],
+ [
+ "00000000001220503e0aaee266bca85de09ce97b0091f24972d1ad1c8afe8609",
+ 0
+ ],
+ [
+ "000000000010a614c60457f8d2ae2bb826d037f52113252888fadda8ed773c9c",
+ 0
+ ],
+ [
+ "00000000585a8b882ecff8aa8434feeac4ef199ca669bd81ed473e37f0bb4528",
+ 0
+ ],
+ [
+ "000000009504ffdb5fe82ad88218fb5e75a8bc185247e30e22d23b9fd9b7f282",
+ 0
+ ],
+ [
+ "000000000ddec7d73bcd653168d82e34cf5746e006bccda8a9c031c3289b9568",
+ 0
+ ],
+ [
+ "000000000cb6620ee4e8cb8b6b4d51251e5961f7ae2e83538ab3a4fef3bcc773",
+ 0
+ ],
+ [
+ "000000000239224a0841738513c1eda712b73266ea958aa75f44a3985ebfab82",
+ 0
+ ],
+ [
+ "00000000002630c7c3586fcc19079300403c54dc293bcfdf8a9981f85a5c31bc",
+ 0
+ ],
+ [
+ "000000000028d8c34f44e51fd71f5401094a983f6566e6d08ce86ec5d1bd639c",
+ 0
+ ],
+ [
+ "00000000000dca95f1828adc3c37b4625f60aeb35a6614a4358322b7a6bc2f7d",
+ 0
+ ],
+ [
+ "00000000d72ec84fda18959ddc474d1a31a3a13b1d94695136c4810af8c01a0b",
+ 0
+ ],
+ [
+ "00000000327c29604996eb7f0a208160969ee4408a1cad277a956334f94e0f35",
+ 0
+ ],
+ [
+ "000000000e1bd41d009c1910fcfee7bf1cc1adb04b0b7a632ac36c1092f01bb7",
+ 0
+ ],
+ [
+ "000000000201a5afed48b9d095b949229e9882ef8bc96767be3097c87264dfb6",
+ 0
+ ],
+ [
+ "00000000003f28e8f3f9c80b1269bb0aa3b57501c12458550ef04fd43aca6a33",
+ 0
+ ],
+ [
+ "000000000029e09fc14e38a6a0103c8c67383f41af7d76998055682525f4ca89",
+ 0
+ ],
+ [
+ "00000000285ce297602995582ba5d32d583d618a6a92643566e25dd36cf2b7ab",
+ 0
+ ],
+ [
+ "00000000657045fa54fac52b8480dc84bd4c418940ba63679f4bd6add6a39962",
+ 0
+ ],
+ [
+ "0000000017b7bb58be05a47ff7c4ead27db750813d6bcf3f99cbcc35324cf445",
+ 0
+ ],
+ [
+ "00000000003a310e39b6df17f17450496b4f5c1593399bfa1ab8b4d39bac9b25",
+ 0
+ ],
+ [
+ "00000000000bfbc5294f003548a9636ebbcea3ba42577821266317676fbc363c",
+ 0
+ ],
+ [
+ "000000002329351dd70c24da2eea5ac19f65b6053c4611aa4eb93bcc2783c57e",
+ 0
+ ],
+ [
+ "000000004ce02f1005aa6fa4d158c6e4fce95ab053d88ae74881dd080c24e057",
+ 0
+ ],
+ [
+ "0000000000fdaaa54cdaade8cfb75245de0747c60c0307ad11be9fe154535565",
+ 0
+ ],
+ [
+ "0000000003dc49f7472f960eedb4fb2d1ccc8b0530ca6c75ed2bba9718b6f297",
+ 0
+ ],
+ [
+ "00000000014ca604d769d4b99fff03ae3ac84d1e8eb991c5dac7c3cd4d9e68ee",
+ 0
+ ],
+ [
+ "0000000000190ab8ecef3a3d5583563851672d81a4d4d952b8cf3bd503c655e5",
+ 0
+ ],
+ [
+ "00000000001204d263b607987fab11e1c19c94b7e3e674cc73cc2fb7b05fbf07",
+ 0
+ ],
+ [
+ "0000000000141e8d7f7ac359a8ae58e35ce6010c25ddd6f1881f41c0b939332e",
+ 0
+ ],
+ [
+ "00000000946344dd06ef5ddd13fb74f20c475daf911ff4e3f1dcdf64c330e274",
+ 0
+ ],
+ [
+ "00000000ec77a7892e48b85bcbaf404d16d7fc93747d7e9e3ba6195a9b6f1525",
+ 0
+ ],
+ [
+ "0000000018a305c04dea8e93e423ce9569872e0ec5af49d23a0e3872b0ad6297",
+ 0
+ ],
+ [
+ "00000000055e32c5f8a86c9a712eeb6440bbf9810ae6da12d0cea2493138a885",
+ 0
+ ],
+ [
+ "0000000001913fcbe67badbce4234e86e35a1ea867ecd69814b5f5ab039b7d4b",
+ 0
+ ],
+ [
+ "00000000002c71fe4403aee704720ceafd21f9f8c9c97a8bfbd25bb46223aa40",
+ 0
+ ],
+ [
+ "0000000000343a42da0c811836d0785c272591facd816f0e7fdcfb1109d8f9a8",
+ 0
+ ],
+ [
+ "00000000000309b182608b3eea7fafd0d72e3c79a0a3a9cda03cde3947e332e1",
+ 0
+ ],
+ [
+ "00000000000204cc04e421c3958a64d7bc024a474ce792d42ab5b48a5a6f3927",
+ 0
+ ],
+ [
+ "000000005eaa010e7255bd37e0b00780575074a74d889e17c4dbc578f917348d",
+ 0
+ ],
+ [
+ "00000000a0d425f62d9196c069286dc6635ded9d027de40070d397e45bd63e0e",
+ 0
+ ],
+ [
+ "000000003355fd37068ce2d5d2a94ef964eeb9b687f21f4a00850a3e6cc4a71f",
+ 0
+ ],
+ [
+ "000000000ca9148dabe9424cd8c96860c90d836ab25970a3e91856764e2e640c",
+ 0
+ ],
+ [
+ "0000000000bde23f829dde8edef35436be4b8978da21fd2c3a8100ef5334e3cc",
+ 0
+ ],
+ [
+ "000000000028bb26f1427fbfabeae65d55a9e59e18230713e40f0f7c9c2dee12",
+ 0
+ ],
+ [
+ "00000000002ac05422d254e597ee6b5e0f8be9b3e2f887486442d720c7766919",
+ 0
+ ],
+ [
+ "00000000000e36d0b6f187dd9601b1d1dcd987c3e0f6a081ffd039c7c5e32462",
+ 0
+ ],
+ [
+ "0000000000048d7b1f2a2a11fda34a5cfeea067ab03e482931e5a0f463f438ba",
+ 0
+ ],
+ [
+ "00000000f780ab88c8a4f4247573a749fbb087a4e3fb6a7d29926de8a9ab3462",
+ 0
+ ],
+ [
+ "000000000313bbe6a940e6a8c40ba091aa1ebbaad135bbbff3ed8ae07cf574d2",
+ 0
+ ],
+ [
+ "000000001d4ab29721aa2722482562670a0d71dc1eb73231c5dafb64756b04e8",
+ 0
+ ],
+ [
+ "0000000006588bcbdec38d19962b96cf0352cbf1b90f3379cc6787d018cdb96d",
+ 0
+ ],
+ [
+ "000000000022e79539a21ac24f9daa2cbddf2bb4a3125f88a5efc20d13ea856b",
+ 0
+ ],
+ [
+ "0000000000dd284b7fee584cc578a10fbe57e8efe6bf6ebacb23c0ac5d46cdf7",
+ 0
+ ],
+ [
+ "00000000001451143787f411c93d5506065c3fb597966f2fd7a4a5c078ee6aa2",
+ 0
+ ],
+ [
+ "00000000000ca977394af1e414dc1f9d83efa007f7226e11d3a00f59a1fdfad1",
+ 0
+ ],
+ [
+ "0000000000011f8caa80580e7a796bbce5b84e60731bf48e03c6ff5c6bba868e",
+ 0
+ ],
+ [
+ "000000000001705beb1376af1af08b437acef6befbe7d3b60c5fbaf6bb7f38c9",
+ 0
+ ],
+ [
+ "000000000000c838f1f45422d93ca9b5838368a37423efa8439ee24b2bf247a2",
+ 0
+ ],
+ [
+ "00000000000111ad857d31d07fdc8b32d17af2522c18bdaccfef449b29d17362",
+ 0
+ ],
+ [
+ "000000000000312a7718fc616b0ecfdbf6066f71ec1a4a8c43f50f02f61cc398",
+ 0
+ ],
+ [
+ "0000000000007d232b217a59b804ef67091c5720a5460c2c16bf97b97a24801e",
+ 0
+ ],
+ [
+ "000000000000177235c33695aced585685b4c500eb76e72caad02e17503900eb",
+ 0
+ ],
+ [
+ "00000000000037f5c5890da7a8e2acd2b0669ad7db648ac43140c637a1c81637",
+ 0
+ ],
+ [
+ "0000000000002123904063f223bc35135c426a4f9a0b74c1907e837b810f0321",
+ 0
+ ],
+ [
+ "0000000000000961db809da357d91a9341170fafef9f24896d8730bd05cf3f96",
+ 0
+ ],
+ [
+ "000000000d2e8fcd05eb874e98cfc3a6e239f6974950e6f50b0487513ecab760",
+ 0
+ ],
+ [
+ "00000000017e362508c8db23fae0431eaed708d9db13e48fd5d318066bf6733f",
+ 0
+ ],
+ [
+ "000000000011b2bc4fe36f90b7ba5a62f974db250bfdc285b70c71148023c7e3",
+ 0
+ ],
+ [
+ "000000000001be28570b378dd5dd2eb3aa495c229913b6757fe8900dfa3cce99",
+ 0
+ ],
+ [
+ "0000000000242bd0bb16d0a5324e0b4b5a83697dabb3b4a059084557478e50b9",
+ 0
+ ],
+ [
+ "0000000000d8ce69d18da32ed52e503d6b5ad48d970b90545f956b2d2af2edf6",
+ 0
+ ],
+ [
+ "0000000000366655bf0cb3dd0cd7801e0adbd26b5b441b77a9e3642597effb00",
+ 0
+ ],
+ [
+ "00000000000dc7aa00d4607ca8374d40d1187f1c084b620edb45fc39bc8d2db8",
+ 0
+ ],
+ [
+ "000000000003baf60d9c6e70a765cf517f66a124509191188e9547ad09edf68b",
+ 0
+ ],
+ [
+ "000000000000e0f476893b8fb4d37e855353075fde73dbc1fe181cc956349f19",
+ 0
+ ],
+ [
+ "00000000000032ed16b7de758abadf4a4fb2df7a101ff275c51f29e1555a89a5",
+ 0
+ ],
+ [
+ "0000000000000a564d03f0f2fe20f6fb5f038d931f732d817641cd7fff3b0acd",
+ 0
+ ],
+ [
+ "000000000000011aa4d0fdcea8d4ca85cd5d548e322e2b6abd17f8444be855c5",
+ 0
+ ],
+ [
+ "0000000000000610588540267a0eb544531047d4c8af0f21fca7cd3d96205cfc",
+ 0
+ ],
+ [
+ "00000000000002770dab5e14843149df8f76b8dc8458ed3ed2ed8a14a6e2e564",
+ 0
+ ],
+ [
+ "00000000000006b70ebc9f75bd32f466602cbd4b86c3c2d2379059542bb8bec6",
+ 0
+ ],
+ [
+ "00000000000000ef579af389fa7674f98a2371063fa8b218c5ca0ad94e21b896",
+ 0
+ ],
+ [
+ "000000000000021b6108dc988f9153383f9501ab9001109aa87902ddd4c8a4d1",
+ 0
+ ],
+ [
+ "000000000000022c02ff22bc0af5201f0e1a14a75879c494731e4fbf999218c8",
+ 0
+ ],
+ [
+ "000000000000032651c988edc1ccd08e82b888cbb8135e24a958ac0c0b640d5d",
+ 0
+ ],
+ [
+ "000000000000015aefdfa0790bed326c38c358c07aac0674f5b2e771258b8df3",
+ 0
+ ],
+ [
+ "00000000000000822e1534c86afef911b67d3fa20cf2b12d93d20d64005f54d7",
+ 0
+ ],
+ [
+ "00000000000000338b871276768c923b1c603fd6150bd054c2287e532e61de7f",
+ 0
+ ],
+ [
+ "00000000000002d0af52c0cae894bf836b61137ace2bd7500abd13a584c02741",
+ 0
+ ],
+ [
+ "000000006f8443a458f38d8731821c07a2fda0ecdbb1cf797f541844d468ce0c",
+ 0
+ ],
+ [
+ "0000000000b6fbd8b4e227f5514979a61d8b0b918d2adc154e585ca926386704",
+ 0
+ ],
+ [
+ "000000000f4f5e49b10278e27d9dee15b92f9d4a257138a206831e0c00188767",
+ 0
+ ],
+ [
+ "0000000002c7e9769bd8ae9906fc5682e937b5c31ab5b5b86e4d70af2c15a95c",
+ 0
+ ],
+ [
+ "0000000000f68a1db8cd387e0a2f93f45149fe1ee4a230bb386313bdd42058e8",
+ 0
+ ],
+ [
+ "0000000000f0f65c360c8f0f9853ad1142f16675dc1175d61afdbef977776b25",
+ 0
+ ],
+ [
+ "000000000004f734e634156511cbef7dfefebdf317e7488aa6c2562572d7ecb7",
+ 0
+ ],
+ [
+ "0000000000002a46a7a16787e8317dc567ae26816324c2035be0186ba54d5cb8",
+ 0
+ ],
+ [
+ "000000000001a593e6f01875b77e270163538d88452779bb557df7c2607c28e0",
+ 0
+ ],
+ [
+ "0000000000004f24cfafa10bd50a452535f64be577a6161e51c7c71542f654c4",
+ 0
+ ],
+ [
+ "00000000597cce73e84b63f08cfcb9b01f5e7621752d8c8e08fabbd6ab5c0dd5",
+ 0
+ ],
+ [
+ "000000007cad379df01247771fff471bc99faea1b86218602f45ab13efc5e9f6",
+ 0
+ ],
+ [
+ "000000000d6085aab25892be49c49d6c0a3949befdc3ddce2faa46b104e1e804",
+ 0
+ ],
+ [
+ "0000000002be5996786b42d6a229093896aea9966b1854ea261e01e84da1f420",
+ 0
+ ],
+ [
+ "00000000002684b72056e270b115d80b12b2f68eac7412355287226aecd9b5e0",
+ 0
+ ],
+ [
+ "0000000079ea27efb24366c87856a9e371c56fcbd59d09d3164a5c2fc15fcbca",
+ 0
+ ],
+ [
+ "000000001694120525dba4548ca54087544da1fbefa51c38f0208d683418825d",
+ 0
+ ],
+ [
+ "000000000693e80d372938f3553151ab9d0a9a6922182591c701df739dc9a502",
+ 0
+ ],
+ [
+ "0000000002950d9cb23c8511937811910b712f73d448e6fdc2e39e029b86848b",
+ 0
+ ],
+ [
+ "000000000091c40056c6a48f33db17764af89c01f62ae653aa5e494146164cee",
+ 0
+ ],
+ [
+ "00000000001f373c47e1a39af4e1ebcd8c88411ec49d6bd520c2781564070971",
+ 0
+ ],
+ [
+ "00000000000809ca4b2170c57958709b867095b1972d80a2ee55359fbd0940fe",
+ 0
+ ],
+ [
+ "0000000000038e7bd66fc3308447b1370dbdd0661c427c512bdbc641ff360fb2",
+ 0
+ ],
+ [
+ "000000009a3325df76e2de1fc1970cc2f241fa8a41da9ad745a0d9666d9ff51d",
+ 0
+ ],
+ [
+ "000000003176e92ff837bf43a48a995c1a321b166475f586ffb4b962e0254a4a",
+ 0
+ ],
+ [
+ "0000000001ae3292e81ca3859b75bccd5bff825cd9f496efd085160c716ed05e",
+ 0
+ ],
+ [
+ "00000000033bdac4f0d36bb912fba28bb5caa54d1b611759a10f79ff3c969cf2",
+ 0
+ ],
+ [
+ "00000000004c6db7fa0e2c9f08693abfeb128c5827b511a5c46c623a103b416b",
+ 0
+ ],
+ [
+ "00000000003d87f48bb95e9431760d0c5f4f93c77d02fce9dd1673e9f5b01029",
+ 0
+ ],
+ [
+ "00000000000e214fc3d8b97571eb75d248ca29f8e25a584c33de8488ceee72b0",
+ 0
+ ],
+ [
+ "00000000000133269b7159b828700d02de770a8cbd91f3d166e6bbc95d8e0dfc",
+ 0
+ ],
+ [
+ "000000000000cc92e2dd933a08f7fd87f84451627982fb66583587858217c059",
+ 0
+ ],
+ [
+ "00000000000030708136c20c4c8216314005b3cb5c551ded33b26cf64d2ff47d",
+ 0
+ ],
+ [
+ "00000000c472a1341d479ed02f31b699e448c035049a7092670b38f4ec6121f0",
+ 0
+ ],
+ [
+ "000000000a358834d6eed41b9b7161a338aba53828111414cdea7552ed15548a",
+ 0
+ ],
+ [
+ "000000000e13e77372daea775c8358916e57ed11835899c14e5140ed9be11089",
+ 0
+ ],
+ [
+ "00000000008252cd0931f94b2465bd4f93e4bfeec6697962c5b034cf3d12cf7c",
+ 0
+ ],
+ [
+ "00000000019812cd6cde3a43831234be71e68118be24a80161349b8b327acb5b",
+ 0
+ ],
+ [
+ "00000000005865499f301adfb59f8380743e4c3b3ab220ca4eb97dc6628df626",
+ 0
+ ],
+ [
+ "000000000015f77e1e61329560a4378eb401fa5bf0ef90b0a014a4d7857ca7a8",
+ 0
+ ],
+ [
+ "00000000e9cbcbb625e8a463ba8e7f14be46ba9538ffe93338784ccad3d992e8",
+ 0
+ ],
+ [
+ "000000000fb27169efcc2873cfaac223ebb91cc5e1e5ad7e9a312d42bedf7c42",
+ 0
+ ],
+ [
+ "000000000c9c96d62ebfbf3fa4003f1d46d175140ab084dee17e8125fa40f24a",
+ 0
+ ],
+ [
+ "000000000311e3a766b1ab2064b68a344a561eb496d595126808ffb166c71cc1",
+ 0
+ ],
+ [
+ "00000000677568c82262ac3a4ca3f909bdfb0b35145ad490fa3fbdc719d06b91",
+ 0
+ ],
+ [
+ "000000000ee77ba9ab657e51fd9140f5c9b46731d9341e98188f929c97d04746",
+ 0
+ ],
+ [
+ "0000000008a67eb9c91a6d74168f3f385270fa942ea00bdd31924d1b6ea11148",
+ 0
+ ],
+ [
+ "00000000017f93c9e0026e90d579e18c83b4a8557f0c00e9b85ab164cf4466c5",
+ 0
+ ],
+ [
+ "0000000000994efa379235c03711a8e6b29895d928b5fde96cb01c02374c0602",
+ 0
+ ],
+ [
+ "00000000b3be9f23c943d71d7c7dbdf6dd672d77a712f6c83e9796a85e4379f2",
+ 0
+ ],
+ [
+ "000000000713e1089b0b2bdcba462b740c9396f822f1c73e090713978a7f1314",
+ 0
+ ],
+ [
+ "0000000002fc44d358401a7ac9ce4ddcb17f3cbac08e40242e755e60ab2292ed",
+ 0
+ ],
+ [
+ "00000000021ef2c04fd30be7049f73b9a8353ac96a467dd5f0b9c1457be1bc5e",
+ 0
+ ],
+ [
+ "000000000023b95b440ccbbdcb914172cf675cd15d6111bd7f5a436a4925d36e",
+ 0
+ ],
+ [
+ "00000000001983521dbffd1b742a6d4b5dfda3f46579fbbdd83a2ebf9a039bec",
+ 0
+ ],
+ [
+ "0000000000044d53dbea312432e68fa90dc2148946f613216dbdeec86f6a67c1",
+ 0
+ ],
+ [
+ "00000000000107667692f12d21a55a72ff1dce828f96872e36c35bfbae475a8d",
+ 0
+ ],
+ [
+ "000000000000252d1d0c01744ec25af801ef7c57e2581c95295070b6a8a85bd5",
+ 0
+ ],
+ [
+ "000000001c1da54e16dc06158677024d9e74bff39bfaec83434ac33673fcc251",
+ 0
+ ],
+ [
+ "00000000b4d0c6ae86bfdf7ba4c205fc3e6b3b6d63836b85e30e9d8bac922301",
+ 0
+ ],
+ [
+ "000000002b16179cb022bf678bd847dd6fc1908d0df04abf0c7874981eb33ee7",
+ 0
+ ],
+ [
+ "000000000e6783554aae41856424d184dc4fa061f40676efd107e6f933a25641",
+ 0
+ ],
+ [
+ "00000000005ae4acbab519895b4b523d97a09e381c9e4b044e642f73b8c0f1b0",
+ 0
+ ],
+ [
+ "000000000010372b59c9595d947064804b75ab21868dd075a3842ab7d2df6181",
+ 0
+ ],
+ [
+ "00000000002f9f587ea304093be049d3142ac0c92f9c68928a4f82d12b929b69",
+ 0
+ ],
+ [
+ "000000000005d4cae51b3c76dc3c61bed0c265c4f228c0c4d1d3d147146c34eb",
+ 0
+ ],
+ [
+ "000000000001a5b6c0e0a0b485a490cb52ccdf9b22596656039b51545bb07be5",
+ 0
+ ],
+ [
+ "000000000000d723d0976338edf55d08edab995dd6283cbb688855f0dca6e8f5",
+ 0
+ ],
+ [
+ "00000000bfebfae90208a82c7fa06c0f61674dbf1e4f9162e370656c38d611bb",
+ 0
+ ],
+ [
+ "000000000c91cd144b2a92ab5024c87f70cc1d76a4a7f26a82a98c5aaad62850",
+ 0
+ ],
+ [
+ "00000000077c8114eb5cfb69c3924c699d0c70334360dd1daa95db0db4816953",
+ 0
+ ],
+ [
+ "000000000348a6443e091db8f68e88a10afad7c6e3e5392247902c4b4feade43",
+ 0
+ ],
+ [
+ "0000000000d63b70351e05829ad8a56336521b361b0d50eb7ea1f5b46c25b00a",
+ 0
+ ],
+ [
+ "00000000004658603163f0ede572120a1bbfce8d313aa282ae54d2ffd9fe9079",
+ 0
+ ],
+ [
+ "0000000000048063b410c793db34856f23acfb19a0ce72f5997fa572773378c8",
+ 0
+ ],
+ [
+ "00000000000228fb6e587fa593ff8b4764064bba8bfc2f43ba5b1f12af33d04a",
+ 0
+ ],
+ [
+ "00000000000082e3ddb75c0ea2a98922b1556ce10346f9bb0cedd97ccb3fdf62",
+ 0
+ ],
+ [
+ "00000000000005571b54d4886b44b81c21dfbefa554cd7c23430e5aeff6b5ae2",
+ 0
+ ],
+ [
+ "00000000306a603ca1a0d961e08e103a9f13f3615163c3373d1bd2a67cadc2a7",
+ 0
+ ],
+ [
+ "00000000195d93ba7ae19832b622de86ebdadf3c78f1751ef2b2e9b0e3a530d8",
+ 0
+ ],
+ [
+ "0000000000476d0d00cbc68bb20b4893f0e608b02a1e029b8c6c73e169c49e69",
+ 0
+ ],
+ [
+ "000000000051348044bc10fc05960c244c3ccd3b3b6c145ffd9958a1c8bc0215",
+ 0
+ ],
+ [
+ "0000000001e4df369203badca9aedc28c240d592b12d284ce0b0463fc7537c09",
+ 0
+ ],
+ [
+ "000000000091cc1ccd448b0ec9185618a84dea96f52477cfb9b9ca2b60cebe83",
+ 0
+ ],
+ [
+ "000000000024a50299c0ef0c6dec9c64336b6cf5c1a1b0013e22fd4fcee1d7d1",
+ 0
+ ],
+ [
+ "00000000000349248c1df06c3783d1270cd97ce7f605b9036fca0fdc2f0fbb96",
+ 0
+ ],
+ [
+ "000000000001afe6793e7427a3d780876d26eb7f2ded92563f991bf7302aea69",
+ 0
+ ],
+ [
+ "0000000000007148006e139e24d9fccc307661c9a0cbcd1af983487c2f0780c9",
+ 0
+ ],
+ [
+ "0000000000002734722a341984738177a3f6f264291424e4984f2128d921bf29",
+ 0
+ ],
+ [
+ "000000000109b02caaa95e49a477757a41a42daed40e92f54fa09e63f5538cd2",
+ 0
+ ],
+ [
+ "000000009a11c7ff8b8fa7fbff5a04c25906f701ab5bd67195736f9ccc839ab9",
+ 0
+ ],
+ [
+ "000000002b1d77f8e0cd60af1c62ef6d381e8905665b15a7fbc546d0c1a45e18",
+ 0
+ ],
+ [
+ "0000000002588cb017de9e2f23cea7edc5082f1b3faec890f9252d556efeac40",
+ 0
+ ],
+ [
+ "00000000008b07f177adc24a4b1a64d2dbcfbcc903ba861d493e11d6b33af7dc",
+ 0
+ ],
+ [
+ "0000000000bab8db5020aa8e052165275e8eb3e7c843533246bf6e4c8374757e",
+ 0
+ ],
+ [
+ "0000000000138488fdca8bfc327e6dbd6c72c5f1dc5868d9c0ea886665b9b56b",
+ 0
+ ],
+ [
+ "0000000000094021fc954efbf08be667fef1b817e8715d4093a561fc30264aa7",
+ 0
+ ],
+ [
+ "000000000000e8183e64072db79adfc6c09b650c4178001be3fade4050b06005",
+ 0
+ ],
+ [
+ "0000000000004c93e8661c75974cd191c68dd66999da4f70d039c0ba4a12b970",
+ 0
+ ],
+ [
+ "00000000000021c675b3ec404bb996f5e68f9eeceeac6946e5a6822987824d33",
+ 0
+ ],
+ [
+ "0000000000000ad85684d30f25d1ec34638f099df2f33b418a07307c68fe3c2d",
+ 0
+ ],
+ [
+ "000000000009c6add76ac42a1942c4ce74d25d1b8975d4e3ac8932185e785a44",
+ 0
+ ],
+ [
+ "000000001e7d828d354716881683eb6fb5caec5d91afce298e4e3bcee9574924",
+ 0
+ ],
+ [
+ "000000000a0e438ab203d8fd3e56100f2f14759f704bff6c699df0bb4e9aad64",
+ 0
+ ],
+ [
+ "000000000b7d5c2895df8bc1fdf5d31e0f663564cb5cff3b18642c44a71b6248",
+ 0
+ ],
+ [
+ "000000000193209ecd92fce00a75975446423d94a325ed525c15d5ab921da273",
+ 0
+ ],
+ [
+ "000000000020835bdc30ac67efdbc785d15186914bc14e86387f97450df46418",
+ 0
+ ],
+ [
+ "00000000000c9078321f0030214c75e170b01ec664d39bab1b1e48460a54eb63",
+ 0
+ ],
+ [
+ "00000000000ac68b63d486ade190dc9108eb3730d25e7537649fe21c30e0121f",
+ 0
+ ],
+ [
+ "000000000002a94dfc5f4b677b251a7a7647dbb99c0803df8658222227fe3e3f",
+ 0
+ ],
+ [
+ "000000000000b076bbef0e50593b1595ffb3d571e7ad95dbdf06dca8824ef7f3",
+ 0
+ ],
+ [
+ "000000000000167075c8bcd24233d25cd268271c0e8fcb6f301ee1b6f6ff0341",
+ 0
+ ],
+ [
+ "00000000013107aa587bcf12ac445330ff0325d73c5253f7e6a49ed8c50257bb",
+ 0
+ ],
+ [
+ "00000000090ff53d49c9ffd51511af8d5cba2038a8e25e3b17186b1bc941f43d",
+ 0
+ ],
+ [
+ "000000000d9e704d5607f77f8983cc56069571a3761d5bd5da55f05ec5d8e844",
+ 0
+ ],
+ [
+ "0000000002b2b4c0950fb6390f0ae860840e84eb0a82e5e8a9bc37c14bbf43b0",
+ 0
+ ],
+ [
+ "0000000000be10137a2434dce1d97850b768ce878c1c80ec905f6e9f21e65fa7",
+ 0
+ ],
+ [
+ "00000000005cd966f80183d4c048e63a5c14f649298dfd261d989d9e3c026bf4",
+ 0
+ ],
+ [
+ "00000000000e8f30e55006a4082380c4b1a372b7ad919d3a9b0a52fe5ee881d3",
+ 0
+ ],
+ [
+ "0000000000018c70a4c27bdba237ad19ebae5d3ca23f1394ccc746d73669a1c4",
+ 0
+ ],
+ [
+ "0000000000022acc8432c883953227786f7a6560aeaf0176d232c8affa5b25b4",
+ 0
+ ],
+ [
+ "0000000000001854e95b28b4efcb2cfeb08c76d8cf1fb03f2055b3fb758f3a1c",
+ 0
+ ],
+ [
+ "000000000000187080c2c39f5a3ea8be72ac4d3ec0d16b21cd34f1541bef23be",
+ 0
+ ],
+ [
+ "0000000000001593766a3c63b524f658ec7690df467cc7bbcebbdb56385500d4",
+ 0
+ ],
+ [
+ "00000000000012d6966dc51a41f2c617192169ec8418405e164ba83b9f7ecdfe",
+ 0
+ ],
+ [
+ "0000000000001d0c7d0a2605e127b00448b71e756ad96625116ab8ca18f74900",
+ 0
+ ],
+ [
+ "000000000009cb439ea49282d257595ad1f7602856c16cc26fff423f7783c792",
+ 0
+ ],
+ [
+ "0000000000889282b98336c994d7420a639221e0484b511227fd616d78dbd028",
+ 0
+ ],
+ [
+ "000000000071a4a2ad6767864bd21239c74c9912a40ca9fd3b209e21b66460d9",
+ 0
+ ],
+ [
+ "0000000000f3ed2c3c9a7c3a7291e859cecba8cf9243d23a4892e6be8ea9b70f",
+ 0
+ ],
+ [
+ "00000000006a4258ffdff8b7f6f4f685ce18c6eb1d7a1cf501ca9e02fcb7620a",
+ 0
+ ],
+ [
+ "00000000004af78f1a109d1267a9c24d69c6a4b30fea49f0efa6c8834cf394f9",
+ 0
+ ],
+ [
+ "0000000000193bf3efbb145747198470a81b2cd33c991057676742d5c22a64b2",
+ 0
+ ],
+ [
+ "000000000006b436798c7e4a8c3bdbf054a66707feee5a18ce9ca57eb95bb48a",
+ 0
+ ],
+ [
+ "0000000000001db50c7caa3a02ea4f173343f958f334a8bf3f8638add9e69b34",
+ 0
+ ],
+ [
+ "0000000000003c621629cc0bcec5968d61d2e42c6673de4d46555118ad5001d8",
+ 0
+ ],
+ [
+ "0000000000001262bef2918265f6dd4534013a4650444054fb4f5e490c5ed57b",
+ 0
+ ],
+ [
+ "0000000000000120ceee972d70cc84430006645997c7337976c673bd75cbef2b",
+ 0
+ ],
+ [
+ "00000000ba16134dc0c418a116b97ad5deccd6bf6e3daa028a8a6a80d7823faf",
+ 0
+ ],
+ [
+ "00000000a1a00d6d6fe0660e63402a5a7c7248589211594d37fd800456ce84b6",
+ 0
+ ],
+ [
+ "00000000394766cec78f962c29aaa715b66e3ad34e1f2323dba45e087cb3b395",
+ 0
+ ],
+ [
+ "0000000008b15a3020676f5e084210ecc05f646885eca1cf6a10e9ae9e3995cc",
+ 0
+ ],
+ [
+ "0000000002cf7eb98abe784f6e516670a88b9028a6faabfd099a364c2dc5c42b",
+ 0
+ ],
+ [
+ "000000000054015fec337a9ee43eea501d2292f031f5bc1f09758d20f5cd3135",
+ 0
+ ],
+ [
+ "0000000000068d24d31a9f1192d848155a2f90939627bc456c9a337135a923fa",
+ 0
+ ],
+ [
+ "000000000006262bd09358258edcc455f9ba46b7f9d6e69d0f6b9da89488a4a5",
+ 0
+ ],
+ [
+ "000000000002327bf77ae67961463ea98a78dab06c24ac7d58b1727c5f856626",
+ 0
+ ],
+ [
+ "0000000000006672235c1606fbacd7861b16b267d203b4d687708eeb1fc25e6d",
+ 0
+ ],
+ [
+ "000000000000ac0c9a39a47313a8715f125c46d6ea8be8741b99b1db4a8aae47",
+ 0
+ ],
+ [
+ "0000000000007e93f6578e7856aae0ecf6341e1312664d9e1d812ff254c37ae6",
+ 0
+ ],
+ [
+ "0000000000002a980acdb1443926875e7d4a57859b2b45ce3fa92c7716319f62",
+ 0
+ ],
+ [
+ "0000000000683bfd82c63514bc58a80daf699a6bcd040bb2a499540baf52463d",
+ 0
+ ],
+ [
+ "00000000373e6262928d7a6cac965b294aef35f90b72c85100ef91501775e06a",
+ 0
+ ],
+ [
+ "0000000000f7bc44061b65c62d4d7747138df127dd2a30f583c3ebb66a25c7a4",
+ 0
+ ],
+ [
+ "000000000212a71c38d0e13ab7c5646c949d4b7ca23afedbe351a43b7607043b",
+ 0
+ ],
+ [
+ "0000000000a836e88f76ee5dcca1e884572f32f4460a3b024280738d76e98ced",
+ 0
+ ],
+ [
+ "0000000000413f6c1b1c9841961636bb3290f2410ba0731f3522c4ff3faa2e0e",
+ 0
+ ],
+ [
+ "0000000000082336107412226110ab2a53016d4faad4deec048828507a300248",
+ 0
+ ],
+ [
+ "000000000000a91e7a3f35a23f01621dd051e314da617714991467131808d3bf",
+ 0
+ ],
+ [
+ "000000000000cd6576950f6f238227c3ba7f62405ed1bf3af4878c6dc1b04635",
+ 0
+ ],
+ [
+ "0000000000674099e9741e44da03e9531402a2607a19a65660b57470340828db",
+ 0
+ ],
+ [
+ "0000000030c4744001ae85f9e6b46ed0664449927b86b8fbf25b22b851d23671",
+ 0
+ ],
+ [
+ "00000000002f5095ad1a12eb9eedf88ce1e7268368461b6b4e10051148f436cb",
+ 0
+ ],
+ [
+ "000000000057d3e2a77eadb8b9613cb839ab02a96094dd5d0a6d1f09026c3936",
+ 0
+ ],
+ [
+ "00000000004e0a28be887d6ed037cd9102cbbda7d6c9e584ba51f2c2dce96232",
+ 0
+ ],
+ [
+ "0000000000211346d8099f7ecea72481c4cd45591f5e0d7e347725ac2162f142",
+ 0
+ ],
+ [
+ "0000000000199ae9fc06c5acee766db6033b86f76c266cadefe1461c611c2198",
+ 0
+ ],
+ [
+ "00000000004c9e5748558d4f5a75bc824171e3b958152dfd6844330f1e907f8c",
+ 0
+ ],
+ [
+ "0000000000137addf1521361dad1ee007eb9e6dd4eb8441492ebfaa3c240d556",
+ 0
+ ],
+ [
+ "000000000054d4c77bb7964e5327c35760d87b890ea336aec5ecdeb783350738",
+ 0
+ ],
+ [
+ "00000000006b7b06d04818e97a4df66164b471912f88d9cd02de4af6c8bbe74f",
+ 0
+ ],
+ [
+ "0000000000380fa9858e3e90335c061a3776a26bee1e8b6851de33ec63670782",
+ 0
+ ],
+ [
+ "00000000000842598b03fb79ce7386e9f9181a02dcf1effc8f70d3ff7368ccd5",
+ 0
+ ],
+ [
+ "000000000003d3475edecd733fc7b82432882d9c9f1350a98ef8921b87db4dec",
+ 0
+ ],
+ [
+ "00000000000000e330a8d57a38dbcc0b0a5dc7a4210f231b8082b9be5f9e4bce",
+ 0
+ ],
+ [
+ "000000000000218ff87fd50cfba2fd04203a78d2600cb2c4dcb039d803426e19",
+ 0
+ ],
+ [
+ "00000000007c96e6e3ed3146260348ac79ea7dc2ec2ae6bf8dc203400a37721d",
+ 0
+ ],
+ [
+ "000000005abaa10bf7260470c28ba32f1755b4cfd3734aad580681e39a9605a5",
+ 0
+ ],
+ [
+ "00000000005e77c226e6fffccafa56055e68f0ea0a30101e6a243ab9b3e07db0",
+ 0
+ ],
+ [
+ "0000000000e989fe27f85b89c1e852d7bc94b09033cc6c8b32fbbbd9383a9ae1",
+ 0
+ ],
+ [
+ "000000000091a1e962438583146293ef34156962445ffc5e81e4d0fe327d37ac",
+ 0
+ ],
+ [
+ "0000000000477978a6903217e2817d10e99bdfedb4f8bc396b96fd5b0b93b522",
+ 0
+ ],
+ [
+ "00000000000bfd9e5f13a9c03c48e8b58a937cf1ae2849160f1ca11f8fcced3c",
+ 0
+ ],
+ [
+ "00000000000158dd3c31b6379887b4353ef2898c03b7ce55458fcd57cb6f0639",
+ 0
+ ],
+ [
+ "00000000000029d7009eb56b9d38366005576b82a9b59fc845522a34ad36a38a",
+ 0
+ ],
+ [
+ "0000000000e6e207a82b8ad7136352204bb8e9ccfcd25885a715d3c65cbee997",
+ 0
+ ],
+ [
+ "0000000000fadc4429f50fc534ccac4db5e51a313df25034d6c5c25f7e83448c",
+ 0
+ ],
+ [
+ "000000000019c58defcfdab6c6ab9497685e61118effda4c2613bf44be19fcbd",
+ 0
+ ],
+ [
+ "000000000006cf444d846093c5045d42ddc0986ca805f261476d0fd2eb474c39",
+ 0
+ ],
+ [
+ "0000000000d0856a3d6a1e5b1ac7e388cc029bd8410b3b1489598974fe470568",
+ 0
+ ],
+ [
+ "00000000003d9aae63ed532b78082ca5386211e22410fd24ebd5318d1a4cd1da",
+ 0
+ ],
+ [
+ "00000000000345003879f86021a6d5e3fe93813246818c145947b7e225691177",
+ 0
+ ],
+ [
+ "00000000000175393730cde3e49de7af2b81ae736eee005a9f9c4a1e878c52ec",
+ 0
+ ],
+ [
+ "00000000000087a8c621c879aec2a897258632d6aa631b9a38ba4d564e08682a",
+ 0
+ ],
+ [
+ "0000000000002ea641b2975935bd9caf337b51ac9f9bb90a54f6ea6ee5d3112b",
+ 0
+ ],
+ [
+ "0000000000000c544f9b6a8cbab6d25caf949875622bf75139234850b10affe1",
+ 0
+ ],
+ [
+ "0000000000000f66fc4e37232a29f3389c493863a980d58a1d570eddd5268999",
+ 0
+ ],
+ [
+ "00000000001213fe2bbb8aacb1fc14983586e09db964151cb507956a81b35f25",
+ 0
+ ],
+ [
+ "0000000000ba82c2160602ddc1913bc4c133ad0af8848e014367c84110d00e05",
+ 0
+ ],
+ [
+ "0000000000b7a98b364b1cf9521275a915c7a1b3a0f0c052c7d8efb620ec0870",
+ 0
+ ],
+ [
+ "000000000047dc62db23540ab4aee43e54812aedb623a2a158aa3244fc784722",
+ 0
+ ],
+ [
+ "00000000005291002da10e53c3855882251a6e5a425b5e639ef9be3bd05767ca",
+ 0
+ ],
+ [
+ "00000000005ffbcbc0d9b380584bdc78050a6f0c3582b4c9c5103a150cbc71f5",
+ 0
+ ],
+ [
+ "00000000000a7a69cc06b0a68b27a8fa5d29727ec3b6db8d32d61cf7489b5ff3",
+ 0
+ ],
+ [
+ "000000000007212eb8c49758d98cefaa6098da2b877a6055be341f5f7c0ad301",
+ 0
+ ],
+ [
+ "000000000068d1099d8cf3f43f6d164f2925b1d52ede75640cc65ca020e1de1c",
+ 0
+ ],
+ [
+ "0000000008d5ddef4468a4414bd08184c2eba0ec536b85a743b1091828a6a884",
+ 0
+ ],
+ [
+ "000000000acae40db93b589783b0cde70b98552955cb3c12f08de1b417d9008d",
+ 0
+ ],
+ [
+ "000000000066a51eaa3a54036f338719da3d5779180c0bc3787b533410de90e5",
+ 0
+ ],
+ [
+ "00000000008b521677a6e897950aac69640e52efb01b7af10bba3820ecd09a89",
+ 0
+ ],
+ [
+ "00000000001823f0e399311cab0fcf57403e094feebf99b22030bafd2004da87",
+ 0
+ ],
+ [
+ "00000000000bf821c2abf5bcd00ca96439ddf5b0b593be5601145fda5338efdc",
+ 0
+ ],
+ [
+ "000000000003f4fd19b2af0141289177014ecc6dce6ea8fb50bab93d4a291095",
+ 0
+ ],
+ [
+ "00000000000011842d892a02e55ca594caddc9f3cea1979ddffefc070eda8498",
+ 0
+ ],
+ [
+ "000000000000208aa0259d20f51c0e7b8895e18a93aea79af9b3832e710ef134",
+ 0
+ ],
+ [
+ "00000000000007218f849e72dee1f7fb6fcf36f3b6745c6468187ed2ed13287f",
+ 0
+ ],
+ [
+ "00000000000f79f656cae641c2b74554c6ecd673c0c7550671c4c2af940661b3",
+ 0
+ ],
+ [
+ "0000000000199b4d178c05fd1c3154c9a4632eadc7bfc734c4522176c977ce8a",
+ 0
+ ],
+ [
+ "00000000085d0682d481635cb2e6de2e4d9884589455a86194f0b222f9acb3c6",
+ 0
+ ],
+ [
+ "00000000015972a5a6786a14b009bf582c4bbf7b9854591dd8d26f82b43ddaef",
+ 0
+ ],
+ [
+ "000000000064bf72b7bdbfcbe96dbbd0efcaf7aa94c0f92cb4e6662819468fe4",
+ 0
+ ],
+ [
+ "00000000003df36b7962bb4ad62266c462382eddc93f4bfeac464b95f7a89ee9",
+ 0
+ ],
+ [
+ "000000000006516d3a9f424eb61db5dfb85aeee29708b78c65d24827bd926263",
+ 0
+ ],
+ [
+ "000000000001c1709fe1b294712638db356e89155650f6fbecde79ec47a92af7",
+ 0
+ ],
+ [
+ "000000000000dfc23251344b593c16c28cd195abcb337519d7bc82175721a033",
+ 0
+ ],
+ [
+ "0000000000000aae2dd2bf0b8581d137fcfa3d9c4cadbe3ef3834d7cae4268c0",
+ 0
+ ],
+ [
+ "000000000000092a5baff3d9a5ae87689b2afe668e71bac3b342c7d383f0060f",
+ 0
+ ],
+ [
+ "00000000000fa906eeff7d2e126698d88b8cda01d32ea2c039c26984daaa17a3",
+ 0
+ ],
+ [
+ "00000000002d4315e5bdc2bcfdb245b914130764a50943a2b2e02ea3acf5c47b",
+ 0
+ ],
+ [
+ "0000000000fc2bc9bb83e04cbe922d64719295bfef6320027725402306bcf1a0",
+ 0
+ ],
+ [
+ "000000000142690e7c334b97612746d6db208e6153bdfa8479d86d1b575feacd",
+ 0
+ ],
+ [
+ "0000000000629a7820e8cdbbed18dcfe16c992152badc745ca73b9b34e53fb0d",
+ 0
+ ],
+ [
+ "000000000023c2e9dbf3fe03248e40f4ec3fb2dc81ac573d5a6a4f490c701877",
+ 0
+ ],
+ [
+ "000000000013658a43b6d1c4be95fa36e32d3edf80716de3a8f7e98858016adb",
+ 0
+ ],
+ [
+ "000000000007c847295d8c4b6da9d8a64b57c3a2307e64387bf8882b9d35d6de",
+ 0
+ ],
+ [
+ "0000000000032bf90b823332af80bd2ea18f411f081c7dca8f2fe79d9215526b",
+ 0
+ ],
+ [
+ "000000000000001bc0655da6f24c6952e811006897a0c6dd8b6bd94f178636c8",
+ 0
+ ],
+ [
+ "0000000000001e1d09b15393190cf686e25488db7fcbc2f1ebacc8165fe6e3a0",
+ 0
+ ],
+ [
+ "00000000000cc79ae066badb4157def4067057cefd705bf87f1d832845a7ab36",
+ 0
+ ],
+ [
+ "000000000014408398244b94b4eff6b54875802ede6df2d1d21915333a195719",
+ 0
+ ],
+ [
+ "0000000000114135a1bc757110c05162fa649b694db9569be117e34832c87257",
+ 0
+ ],
+ [
+ "00000000009b15fb2bcee1af904989ba0761e4cddc6b3ee214c0bb07dac6211f",
+ 0
+ ],
+ [
+ "000000000012be506dde2c54adf355bdb41a457b0abec436202a3be73f0b052c",
+ 0
+ ],
+ [
+ "00000000000963760ceb5fc65570650d494805e05c9d753f3ea6d44247ad3d08",
+ 0
+ ],
+ [
+ "00000000000bfec54977673f68b6fe5f088398e697d778fa7987f8bab6a70825",
+ 0
+ ],
+ [
+ "000000000000e7f428bb413c17032c0031af0d26133ba93f744a5a0c16cf7e1a",
+ 0
+ ],
+ [
+ "00000000000036bc80378323c6eaff8ab350b6d89955f602960cb7c93d2feb4c",
+ 0
+ ],
+ [
+ "00000000000f0d5edcaeba823db17f366be49a80d91d15b77747c2e017b8c20a",
+ 0
+ ],
+ [
+ "00000000001ff8fd57798082ab5a7452ada211e1c3be38745155505601498829",
+ 0
+ ],
+ [
+ "000000000020f960b535eac585e5810ad64f158c1142f0eecd925c8058172933",
+ 0
+ ],
+ [
+ "0000000000067bd89409368d221507a160e5c45972eeb01efe210054fe8e7d85",
+ 0
+ ],
+ [
+ "00000000003521f2d5ea3232d4835ca6c6bae083ba90458f67d4cd765ce93b09",
+ 0
+ ],
+ [
+ "000000000005ab3ff3a0c484eff7b571fb78ce27d93f77a480074232e5ce0c1d",
+ 0
+ ],
+ [
+ "00000000001048c9eca7cc1cbb86946c04498052071f7e7c775bba565ada337c",
+ 0
+ ],
+ [
+ "00000000000154caacde41be616f924d7d478812148242fba85605eefec9ac61",
+ 0
+ ],
+ [
+ "000000000000c34f75bd6f338c0206a31a8d5021cc2ded51e88a6ef4fe686d10",
+ 0
+ ],
+ [
+ "0000000000001e0581d86c49a6ca14ba88639ef908abb09210b57989e06b1a1f",
+ 0
+ ],
+ [
+ "0000000000d0e6dc0bf830b50bde3e400e16ec4f772f92a55390e62d4aa73af3",
+ 0
+ ],
+ [
+ "00000000069c2501a2f32cc69af72a602ff674438ae04dd05516f72a71b9ab26",
+ 0
+ ],
+ [
+ "0000000000c926b38954550c9b8d363ff058c2eb135eebdb3e640cfa67df803d",
+ 0
+ ],
+ [
+ "000000000011e9ad9c18e9e2095c3662af5be1e918dff653758583aa45dc8197",
+ 0
+ ],
+ [
+ "0000000000f311624ff4dcdf07400d0d2fec8b16b14c1c16babc377a2d85ad21",
+ 0
+ ],
+ [
+ "00000000002e455cabfdc2a8955e8ddfe717b12efe5b80937b0c0ad6ac977fc5",
+ 0
+ ],
+ [
+ "00000000000fed8889a22339b340f599ac7908e790bfc3cfca9b78078a52d228",
+ 0
+ ],
+ [
+ "0000000000012ca4492956b3f859b00e5db14b54d422cd95c68c7150743db365",
+ 0
+ ],
+ [
+ "0000000000004c58e8f7bac59eb4a036764a4d8e0da51c0290858ab14fb72481",
+ 0
+ ],
+ [
+ "0000000000002f60bc99563ff5b4b800c176fe8bde95e8f968fd6b53d74c9cef",
+ 0
+ ],
+ [
+ "0000000000000bffd10a3fb0b5b86d8b2561f39d07f8a4c41dfa08e3e49b7db5",
+ 0
+ ],
+ [
+ "00000000000006a296be9cd8fd4e3145c146863adbe08b71831abb8a869d032c",
+ 0
+ ],
+ [
+ "0000000000000c557f496e82891039ff22e277bd604be6e2e8b95e519bee91f9",
+ 0
+ ],
+ [
+ "0000000000399b30d2111c4bf3051c1f7f2f35bba7ff290d92393341ae47df55",
+ 0
+ ],
+ [
+ "000000001f88733439e4e8d3c474504aed62037faa16f3845b4c671f69732e26",
+ 0
+ ],
+ [
+ "0000000018aa2f93d2ab76a7e2f1bf5b565b4a1b0ececb6ee46490984f6c0d4b",
+ 0
+ ],
+ [
+ "0000000005e22674fcf65ce7be896a0557205ab26d1f76d73a717f5f14a6d6ad",
+ 0
+ ],
+ [
+ "0000000000223d866b324c097973210f8fc715c9535908359d61d8e1ab2f0100",
+ 0
+ ],
+ [
+ "00000000002b321fd6452ab43849bd7a781953ec4485554e0fdc579f2a52c90a",
+ 0
+ ],
+ [
+ "0000000000173132748c51b5754b0341232325bd118455bf3c8d25164d3eb92a",
+ 0
+ ],
+ [
+ "00000000000143158cdea5fbb9453bbe1a7a900e6feba1e2193e4f5c106d9fba",
+ 0
+ ],
+ [
+ "0000000000014677751456af5630025b3d9921a4eafb4d36a06498f0c6a84c56",
+ 0
+ ],
+ [
+ "000000000000243976cf2d30ecd3cb1fd0b805fba4da92d2758f78e1c6f8ae92",
+ 0
+ ],
+ [
+ "0000000000001323db1ab3f247bcb1e92592004b43e4bed0966ed09f675cf269",
+ 0
+ ],
+ [
+ "000000000000017a410c22c4b6caf710f5ccf005d644caf276ea8626a538798d",
+ 0
+ ],
+ [
+ "0000000000170b2b1374e3a0dfdce2fbc5e302e1e0e9fb419dc057c9959902d1",
+ 0
+ ],
+ [
+ "000000000015b4fad4d929630487680cda2d3aada138c58cc08241ef6dd4ab09",
+ 0
+ ],
+ [
+ "00000000000abebab869f1620843d413a3d9e06dc7d9f5201a414d547ace1f99",
+ 0
+ ],
+ [
+ "00000000000b0bdaf05c2fe8b12ebd2372f49d8eabcfbccdadd68b5e5b7c9565",
+ 0
+ ],
+ [
+ "00000000000ca1af42ee1be2c8895d94f39dab5fcdbe0b4b4065f4be534e7294",
+ 0
+ ],
+ [
+ "000000000069d0cc8c0452bf86cff87db05232f801a162acab2d080d6e4e9ea9",
+ 0
+ ],
+ [
+ "000000000019c7f7685f5bdc3afbb5e978cb3f4f70fea7b2b410139741303b53",
+ 0
+ ],
+ [
+ "00000000000d3874ce21db78f4d1883ad9ae8b26c1d7c13f3d723ff85629d595",
+ 0
+ ],
+ [
+ "0000000000033f87c25275ff72b58630d8da90221f2c84bcbd77c8e615709f8b",
+ 0
+ ],
+ [
+ "000000000000dc72adaaae6483eb6737de7d21b3a24b2426330e80b078ceaed1",
+ 0
+ ],
+ [
+ "00000000000002fb1337228db02ac464565271f22f045c1b6ee5e449f057a829",
+ 0
+ ],
+ [
+ "00000000000001902376ff640d3088899af0819dbd15f602156a13ac2fc8e94e",
+ 0
+ ],
+ [
+ "000000000000007ee49761a1c8284a3b8acefa39e37e455be4773d648e2db794",
+ 0
+ ],
+ [
+ "00000000000005b4d495a77f57018dbc72bf47993d494349329a3c653f04ab93",
+ 0
+ ],
+ [
+ "000000000000009dcb3ae6d68828e2f5ccfd58780abb260354e74484106f81ce",
+ 0
+ ],
+ [
+ "00000000a3ceb118021fb42d39be52db951c6f852bb9a241046e972706f7329a",
+ 0
+ ],
+ [
+ "00000000574e8e1c27fa54c77b4e7cd1b79de070f0d3ad5b383206ab9777d983",
+ 0
+ ],
+ [
+ "0000000039d562f640c1743421d53e7e04c3e8ba222c339fff6f3d25b1d4a7fe",
+ 0
+ ],
+ [
+ "000000000001cb1559d55c697871e18d5c26800f77fb11587241bfbec3b15e26",
+ 0
+ ],
+ [
+ "000000000006e01a93090319756c7ca826ef655feb0cc2ef9abcc59d67de5e5b",
+ 0
+ ],
+ [
+ "000000000000a81aaf5a4c013032638a077af6aad8bc449d74daef8ad3a74419",
+ 0
+ ],
+ [
+ "00000000000087d0574963c1582f2161298e2de5e48f74566291ef9afc2be24a",
+ 0
+ ],
+ [
+ "0000000000033251e71c347cd663945fb68efe82a8c6666c0b41e93f1c46658d",
+ 0
+ ],
+ [
+ "000000000000f592857e6f0e4711b5b93fdf95f2b21a5963bde15be750a07908",
+ 0
+ ],
+ [
+ "0000000000004353c8426e18b942a5012934ddac8322b86d6ab98ed7c0ee86ed",
+ 0
+ ],
+ [
+ "00000000004f027845b699f42e7d0d30c530e99524c5f97186ce6a250a5fac42",
+ 0
+ ],
+ [
+ "000000002fc6407edc060df90785082834867331e6746a43ed34a26fbdc5df64",
+ 0
+ ],
+ [
+ "0000000000048733007c91ea3665bd4e1653b10799e3f43abee0fe830ffbb3ad",
+ 0
+ ],
+ [
+ "0000000000025a9b1c5afceba0c78c4b0320797acdc1ad50b4e040f148fbff7f",
+ 0
+ ],
+ [
+ "00000000007ca6d026d27387edc1c5570de41c61bacbcb1dad2c0f300b49e637",
+ 0
+ ],
+ [
+ "00000000000258f683a77ad509da82a4fab24188fdb4b4690e212c50794a9abb",
+ 0
+ ],
+ [
+ "0000000000015111bce7b6ac13c930484e14e31e13e43355cb4d63c8f1782440",
+ 0
+ ],
+ [
+ "000000000001ca074fdecac7749d95f28f10c83a7e13787fd865bfbe505382bc",
+ 0
+ ],
+ [
+ "0000000000001c11a6505dd44ab405fdc07ddfc015f3c1166a5d9352ab58b52c",
+ 0
+ ],
+ [
+ "0000000000000c83f7f8e1cab4efa08d6c68c4555fb6ab542e01b87edd8f56ac",
+ 0
+ ],
+ [
+ "00000000000009561d0ceba15388573d2a994aff24512ec3ed7d7881aa0997dd",
+ 0
+ ],
+ [
+ "00000000007dc7cfbbb94db1fbc076a70a1252fd595686b4d75b2ea77ed6ee9e",
+ 0
+ ],
+ [
+ "00000000000251feb68a8c90852f73aeb29ebda191038737b7edd37c9475f4ac",
+ 0
+ ],
+ [
+ "0000000000013f9a97045ea9047654e514951288911b2c3986787c27bab49106",
+ 0
+ ],
+ [
+ "0000000006e8c37735c61f22bec69f4cb7eba03172349e7012b7704652f3e83a",
+ 0
+ ],
+ [
+ "0000000001f341add5657043d8e50e53ba079fe24966a2668f904be5579c84b9",
+ 0
+ ],
+ [
+ "000000000029a6275cd477d77939424bd183c2f1308a9912f45aa7cc9ed13b56",
+ 0
+ ],
+ [
+ "00000000000a0336239e5e1faedf5bd2eedf38c9a5ba34a832356aea70aeb102",
+ 0
+ ],
+ [
+ "000000000003c1a2b25093a64eb624055f6a3a26e18b8e7ea2d9382ec7a3609a",
+ 0
+ ],
+ [
+ "000000000001bd89bf7e8740ce22adfa6e8793bd1716a647e558ed1742ee8329",
+ 0
+ ],
+ [
+ "0000000000001320421f1bb2c94000e11a621f581fc277c0e2911c3b89f680bd",
+ 0
+ ],
+ [
+ "000000000054ce90a949f5ae2d43c4ace599668c6ccbc50620f6d5705922ea7c",
+ 0
+ ],
+ [
+ "00000000200d16fea4857e6b73169cc593421a57971acdbcaf87a31d7d8d72c8",
+ 0
+ ],
+ [
+ "0000000000e75602181c88f713b91c49de291ed878be305d25b75c0ec5fbe942",
+ 0
+ ],
+ [
+ "000000000081f8169c3c3665f20351dc0fe499612ae232ec0b55858a8e5dc6e9",
+ 0
+ ],
+ [
+ "0000000000d7ad232e7593fb435d125343b8113bbdb3705ab58ac0e18c26cc79",
+ 0
+ ],
+ [
+ "0000000000076df615d887e33193ca2dc0f2fc0e70744512c95da6242e9b1a81",
+ 0
+ ],
+ [
+ "0000000000084a62093d1929843e74456686429b698a7ea9b1901c1565779f58",
+ 0
+ ],
+ [
+ "00000000000251d1da01e9de9fcaf3ca3a64bff78a5faf51a8e697dfab6b5e4b",
+ 0
+ ],
+ [
+ "000000000000609a8798996b1f1fe0b66060a628eadc380d0d369a2318c2d0ec",
+ 0
+ ],
+ [
+ "00000000000014770aeab044a022e86d888a6ede75b6474022c71aead3a1db74",
+ 0
+ ],
+ [
+ "00000000000004101d04ebc90ade5d4b911aa13c038ecf25e9887d877203ddb8",
+ 0
+ ],
+ [
+ "000000007c700410b61eb7ff1aaccbfc3a79e4e4484ad7a2b0eda4d91dc4b613",
+ 0
+ ],
+ [
+ "00000000055ff438a031413ee042fd3c0a2b69be98690542806ff123b7988024",
+ 0
+ ],
+ [
+ "000000002eca5f9f2c3b656d2550662fdee4c95da133eade51a5cae653bc69fe",
+ 0
+ ],
+ [
+ "000000000c679b76ccf0c5b943095fdee8fa466311edbea2c4a05f9430ffef3f",
+ 0
+ ],
+ [
+ "00000000007c6f494e32d5d9de58fa008a770fdc0a7b4a141be5b7c2de3ab970",
+ 0
+ ],
+ [
+ "0000000000d5dcd5a26c8ad29c1293e70401e2f90d8288469df3816b8cc6d4aa",
+ 0
+ ],
+ [
+ "00000000000d754d94f36cacbfb620710672afb1558499cabe17ca62c54a7d3a",
+ 0
+ ],
+ [
+ "000000000004096bb78fba714b130f7f1f929e2803c75a7a85619f7a2b86567f",
+ 0
+ ],
+ [
+ "0000000000020e686c38d44c35896df35f9f1b7723a82a826a5e2393c25ef68c",
+ 0
+ ],
+ [
+ "000000000000504f9af6885c0cb6484109ea205a956c8efae9557a1f5b9233da",
+ 0
+ ],
+ [
+ "0000000000000e8746e52e4320ec17e66434a3936a3825f7046fe874e92275fb",
+ 0
+ ],
+ [
+ "0000000000000f48d818a9a026270c9f733f629959bea25192596d59874b1ce2",
+ 0
+ ],
+ [
+ "00000000eaa9214cb05b241828a1cfb0c4209fb7ea64429815d61f7c1d98939e",
+ 0
+ ],
+ [
+ "000000001f7f915a6002cce4edd5cba392307f3a199a520ee8937327a9135162",
+ 0
+ ],
+ [
+ "0000000009674ee0c606d687bdcddf8e023462927e2902b3381bc4bb862a7397",
+ 0
+ ],
+ [
+ "0000000001f3f3528c083a4b11eb2f04d8bbeca92b57f05d8282909bde78bc77",
+ 0
+ ],
+ [
+ "000000000131917ac459aefb91774dbb42caeca497afc0cfd1766e0338cc7f88",
+ 0
+ ],
+ [
+ "000000000027634444081e1289354cb50034a506bb306a2ac1d8280683771c5c",
+ 0
+ ],
+ [
+ "000000000017a852acff78fbee573329d45bb8b121e9f6fc1e4f687bb3778ada",
+ 0
+ ],
+ [
+ "000000000006789e1a00eca982fb2827f680b254c4a0ecb005af4464f3585a02",
+ 0
+ ],
+ [
+ "0000000000015d2e9f54b1e9419d6b32ce68ae626cdd7f2a1954f22ca39ae0fa",
+ 0
+ ],
+ [
+ "0000000000002f7893bc169165ed9fefb434b6201103f23cc84a747a68ff8797",
+ 0
+ ],
+ [
+ "00000000000008471ccf356a18dd48aa12506ef0b6162cb8f98a8d8bb0465902",
+ 0
+ ],
+ [
+ "0000000000000596f00b9db53c4111bcde16f3781471c5307af1a996e34ec20a",
+ 0
+ ],
+ [
+ "000000000000007b5d2406f64f5f5833c063a6906552e815e603140c00bca951",
+ 0
+ ],
+ [
+ "0000000093ca5d935740a1b25f10ce092fd777c2bb521f3156619389ae78931e",
+ 0
+ ],
+ [
+ "00000000292f3a48559527341f72400a0f8a783aebcaae5bfa0e390dfaa5286b",
+ 0
+ ],
+ [
+ "000000001e852ed7ddf0108d1fce0f4f686f43c8c1b85bcb12c43e564dc7630e",
+ 0
+ ],
+ [
+ "000000000c4bea8fb1e7f3a1f3e6c6b3f71388c0ec7eef3de381853767e89f87",
+ 0
+ ],
+ [
+ "00000000029ef31a21711b55c4300efa38ace0b706091e373f48285286f2c578",
+ 0
+ ],
+ [
+ "0000000000979060786bb008f193d3917e28667bb1b28329f3adadc172e4cce7",
+ 0
+ ],
+ [
+ "000000000019030ceb98013b1627517b45b04ee055ef445813bbebaa25fa1ed3",
+ 0
+ ],
+ [
+ "00000000000adf202247bb794fc9a3c82cd8767143f1e6ed5f60940ee18b09a8",
+ 0
+ ],
+ [
+ "000000000000b19061e2481d8be6183b3d881b0d58601072d2a32729435f6af3",
+ 0
+ ],
+ [
+ "0000000000007a6d34f59b29e8d4da53e51e3414acd18527466d064945fe19fc",
+ 0
+ ],
+ [
+ "0000000000002e66ca213a2c3e9eb5fa62de29feb83880a0bd29f90fca8ad199",
+ 0
+ ],
+ [
+ "0000000000000b4ca10aa100728d0928f37db5296303db1b74ffe29e4a17b6cd",
+ 0
+ ],
+ [
+ "0000000000000143309f6b19567955743775f61f8dc6932c0b46cf5fb11c6c72",
+ 0
+ ],
+ [
+ "00000000000000b04d5409b3ac60cc18c0b9a3d58b303594635a8f75a9d2abd5",
+ 0
+ ],
+ [
+ "000000000000040a2699f62a552703a278608248c2ce823f4cd8845376e9a371",
+ 0
+ ],
+ [
+ "00000000000005cfcb850db7e83d4963994f958bae9b1de1483f5aeb3d449925",
+ 0
+ ],
+ [
+ "00000000000190f80220e70c1481153671a7c90fd856988c183ab0e3d9313df8",
+ 0
+ ],
+ [
+ "000000009374563a06178641d06776f66554c2a094b5319f0801fe35cef72ccf",
+ 0
+ ],
+ [
+ "00000000003e4e6e5e8e4a89e7de50eed104d4a49d2992ff101b6740beec7cb5",
+ 0
+ ],
+ [
+ "0000000000618cd377d14aaa441cbdb92527894f98da316eca81664f8ab5488d",
+ 0
+ ],
+ [
+ "00000000000d977ab2897885fee712f58612fce8c10ffbe9400326fe3429b77b",
+ 0
+ ],
+ [
+ "00000000000c3575b487dd0f938c5bc744fa65ca4ca3a9c981b8bda903ec110b",
+ 0
+ ],
+ [
+ "0000000000247ac689595ed8d62678bfe53e5af13c0f5455e558f5e6bb375c16",
+ 0
+ ],
+ [
+ "0000000000093d175376aa621176511f335a48f824b66d998e8082f85134a48b",
+ 0
+ ],
+ [
+ "000000000000c0c0448fe922f2c737946297d35f2c25ad7cc223e11bbe58e1f8",
+ 0
+ ],
+ [
+ "00000000000016abe4e7c10ddb658bb089b2ef3b1de3f3329097cf679eedf2b5",
+ 0
+ ],
+ [
+ "000000000000242757cea5b68c52b83dd8c2eb9257492074fc69dfa30bd4cbf4",
+ 0
+ ],
+ [
+ "00000000000006813f3dd7726a509fbe3101835db155dfd35d44aeae6aedb316",
+ 0
+ ],
+ [
+ "000000000000053cc4f39cff1c8cee1aff7e289a85dee84164d2d981afc8f17a",
+ 0
+ ],
+ [
+ "00000000000000789724805cf1d37ef689acf52c47a460507f540d5e5ca79bfa",
+ 0
+ ],
+ [
+ "00000000000003d71618bb8952887f65540270a5e54d6246b9419e08831b5e4e",
+ 0
+ ],
+ [
+ "0000000000000251a513a33eadfad67c015f6e3b291dfd0ae1cc4bb3a43006dc",
+ 0
+ ],
+ [
+ "00000000968009e3f8d6e6071e7def68298307717a9af6c2d44986deaae297d5",
+ 0
+ ],
+ [
+ "0000000062bcacb734df83bbfa3e1b9a8dfa570ecffb6c29eaaf8e9498cccd30",
+ 0
+ ],
+ [
+ "000000001d4618c0931bd3c25ee592c35341f30ff3b549a671f637b3c26ef414",
+ 0
+ ],
+ [
+ "000000000418b329df96a004f1b652ad06a7ca295f9f2e711c412d00493f5a86",
+ 0
+ ],
+ [
+ "000000000302bfb88e9027237d023c4b969e106c9a7a23a84103776de7880836",
+ 0
+ ],
+ [
+ "000000000069b9f7d9134fd93c8b7e3af8b26bbcbb5553af02fb6ed644d7fca5",
+ 0
+ ],
+ [
+ "00000000000411ec444240ee91e2777ad18b80dee854e3e838e32209e84774fa",
+ 0
+ ],
+ [
+ "0000000000007c73f322eba4dee5463305227c7e1a8139f1b7b296444f265052",
+ 0
+ ],
+ [
+ "00000000000129adf0f9c0242aedbb9d87935d67ee4ddea758c00344d4b6a29e",
+ 0
+ ],
+ [
+ "000000000000343594e671158b6e1b4b6499f6ad66e2aeabf1f6d295d3dba850",
+ 0
+ ],
+ [
+ "000000000000320f0d5c22ba22b588b97a0e02273034bcd53669b1c8c4eeda1b",
+ 0
+ ],
+ [
+ "0000000000001e8cdb2d98471a5c60bdbddbe644b9ad08e17a97b3a7dce1e332",
+ 0
+ ],
+ [
+ "0000000000000026c9994ccdd027e86f51a2e36812f754bd855a7f9b1ca56511",
+ 0
+ ],
+ [
+ "00000000000002746a820a2c08b35b8d0493c4b5d468fcc971b9c88003e84849",
+ 0
+ ],
+ [
+ "000000000002949f844e92645df73ce9c093e5aac0d962a0fa13eb076eec835c",
+ 0
+ ],
+ [
+ "00000000000156fbda67468ae2863993b98a41227c420246e4bc4e68c84df0e8",
+ 0
+ ],
+ [
+ "000000000003b43c6c807122c8dd10e2a0cffbf72946f41c97c1ab82d416f74d",
+ 0
+ ],
+ [
+ "000000000004e0635c2438b1b649007e5d424b3de846299a8db53049ebf4da0c",
+ 0
+ ],
+ [
+ "00000000000258e4b79e3cca2ab7d12b35ba77fc491572f2e794f0a10f5236d9",
+ 0
+ ],
+ [
+ "0000000000f5816875d9fece105e499b0467b8fb23ea973c48d828a235acdebd",
+ 0
+ ],
+ [
+ "000000000001353bbaec810af7a4c74b4964ae072361c0889ed6d59cf16db6fd",
+ 0
+ ],
+ [
+ "00000000000b354d8c389473670ca6bed7e3dffa069f270d35ec9dad810af141",
+ 0
+ ],
+ [
+ "000000000002fa1f39e7cd8730fa08085ba2b532146ad1ef3b400a13e835ca36",
+ 0
+ ],
+ [
+ "000000000000d2c7943eee59652a9783bff27e474a92ec206c5c6e3cdd58d0d7",
+ 0
+ ],
+ [
+ "00000000000036034181b4d9a84a97490b49fbee4262b9cfb25a7bfc9c0eec9f",
+ 0
+ ],
+ [
+ "00000000000007deb59381cce692f152fc902732d96a7e7d463bc83915b37c0a",
+ 0
+ ],
+ [
+ "00000000ea7d32833462c0f72ade0cae4766e6065caa4e510331929c56d16632",
+ 0
+ ],
+ [
+ "000000000068fce0ddd370d4c8f9129a7bc7843e75fc57666202d3b90239e269",
+ 0
+ ],
+ [
+ "0000000026b4a2212c9c9493f8bd9d5331cab6d8eda8ee017410e58a783ca069",
+ 0
+ ],
+ [
+ "0000000009535ea2dc7e83c31cd17f1db1bb78b0a678fc0610844273de143bf5",
+ 0
+ ],
+ [
+ "00000000008607cbd5baca91d5b8b82ee965aace335744a3e21578af22bee8ba",
+ 0
+ ],
+ [
+ "000000000030dcedae0f5e98c4e176f9569ce76c4d4135bb028fc3144ef381d9",
+ 0
+ ],
+ [
+ "0000000000297c3f0e3fa85731222ba934a955bf513247a72a33c74c498cadbe",
+ 0
+ ],
+ [
+ "0000000000020a0d4a1e8120cbdb486e758b58919c9df12e0edc8ca1f2795e94",
+ 0
+ ],
+ [
+ "000000000000078773afc9023182bfb6534a60158672e6bc6e8aa5052854da80",
+ 0
+ ],
+ [
+ "00000000000102ecdd67800807d9e137357805b9bbf8a439ed86bde5b19fbeb7",
+ 0
+ ],
+ [
+ "0000000000005c3d2e3c7ee737c67ab465533acb233e0df902c1525fc11c3a55",
+ 0
+ ],
+ [
+ "0000000000001a77771650cdbbceff87caa4461391ba6a4ddc9815b5b0ab47b0",
+ 0
+ ],
+ [
+ "000000000000071ec390bbd28fa2a84e52ab5b32901d0723d22646b04ae01dc3",
+ 0
+ ],
+ [
+ "00000000000005c3ec3194f710c6f26ee736d59cc935ddfa574440f39846433a",
+ 0
+ ],
+ [
+ "00000000000001cc3df6924591939269d61ead563b9eb68402a2ca01d7ff99e2",
+ 0
+ ],
+ [
+ "000000008c778b3554ceaf3a13a856acbfe46b5750fb86fd92ba30651c2852f4",
+ 0
+ ],
+ [
+ "00000000107ca31f75f8ea76073dda3c33330b2706c1ec20c3ec240e853b65c5",
+ 0
+ ],
+ [
+ "0000000006ba99b08e7f2869ce113e2ad7464891de7b4cfa96f330d706a2da46",
+ 0
+ ],
+ [
+ "000000000f31036bd51b2818f6dfb90ada9be5019abf55fb15694b181e269865",
+ 0
+ ],
+ [
+ "00000000004fcc101bc47eb7a379b9f608d5c00ac04d2d0ea165ae2937070796",
+ 0
+ ],
+ [
+ "000000000044d5ca3eda838edef0df7e69e1934047f8482822ce58ff7a18466d",
+ 0
+ ],
+ [
+ "000000000029bdfb157be6d400c4dd3370d98afdd8cd3db6f1ada8c19bbf4650",
+ 0
+ ],
+ [
+ "000000000005e9699ad8035caa4f73af781ac2040c87b8aa77459b3607209aa8",
+ 0
+ ],
+ [
+ "000000000001c0ba033f7d85beeaa167c9bde0e192240653a7ff6d9b81679842",
+ 0
+ ],
+ [
+ "0000000000000e0176111f29e800b49c7b8c7226dbbf4df715f1a4f06bcaaa49",
+ 0
+ ],
+ [
+ "00000000ac3bb2cf42192e9053f5384355228a2b3d70b4ece4d45773a5d5ddd2",
+ 0
+ ],
+ [
+ "000000000f29f7b60842b1044b2db7998e9bcbd92f8ec6fe8d159c6d582f1f1a",
+ 0
+ ],
+ [
+ "00000000352f86bc5f9760961a25de009940508bb2cd0b37f378fbc87dc97eef",
+ 0
+ ],
+ [
+ "000000000e9b3086008679ed57f59857f64c3954368ba1088117dbf88d5839cd",
+ 0
+ ],
+ [
+ "000000000015324bd8fed0e61b62bd1d6c663b862cb98ea03c494a92e4a8d0af",
+ 0
+ ],
+ [
+ "000000000020475a181b7a084b341860a72fc0c1fdfcc13a85adeb0471444b0f",
+ 0
+ ],
+ [
+ "0000000000031905c508a975707b74f24e733880382775ee0e6250666473e1d8",
+ 0
+ ],
+ [
+ "000000000000ca38b15d2ea33a6eef505a9c661540a18882f79ba9a3c575a9bd",
+ 0
+ ],
+ [
+ "000000000002739979a7a89fa279303b6606885e750b19e91ed637d7f222b392",
+ 0
+ ],
+ [
+ "00000000000091e935fc266facc2c92759d5468a39aee5be6b76b519a9bc7567",
+ 0
+ ],
+ [
+ "00000000000006e339938254208203b67c3c400f703fc29535fc646699e36e58",
+ 0
+ ],
+ [
+ "00000000000008f6f1d1150d77f93a7f1baa24b65ceb471b1825b2e92ca6997c",
+ 0
+ ],
+ [
+ "000000000000004894e1edcc5421dbcec77d47c5c50bf27b2cff3f1c242c9eb3",
+ 0
+ ],
+ [
+ "000000000000054e97fb5e1a8bd7900f7c329385895761aaa40d11b3c75b0c8e",
+ 0
+ ],
+ [
+ "0000000000000600f4bcc5a89527eede43d1d3342dc12eee1371ab534b0102dc",
+ 0
+ ],
+ [
+ "00000000d1ad5c3ef8c3bb4610b34c264e4ca1ea51c4c8bac18b215e7dc96948",
+ 0
+ ],
+ [
+ "0000000062f6a07ae11f9724b8ba9dc2b7348ffd02b59edd3cd2bf387fab9723",
+ 0
+ ],
+ [
+ "000000000014e4c97c9b09ff20203213f3336b0927fd19d214cef1f544756e39",
+ 0
+ ],
+ [
+ "0000000000d004681880e127aed3fa73255a2e75c2e5c8580cd555526614b294",
+ 0
+ ],
+ [
+ "000000000008093189bba28d40662d6964afc1c0fc9b5c1681bbe32e8bee6c0b",
+ 0
+ ],
+ [
+ "00000000002df10cf8165b2204ef4db6721c8c2119d60463b040fbc81c266bbf",
+ 0
+ ],
+ [
+ "00000000000c28c789e7cd9800b98c1dd32e2dda54048116ff47ed856a14acfb",
+ 0
+ ],
+ [
+ "000000000003e8e7755d9b8299b28c71d9f0e18909f25bc9f3eeec3464ece1dd",
+ 0
+ ],
+ [
+ "0000000000004b95a0103abe2cb97806caca76f6922d9c5df003cf4a467df822",
+ 0
+ ],
+ [
+ "0000000000005f12d2ab72bfa715860444c281265ef77e09dc2d041ce89506c0",
+ 0
+ ],
+ [
+ "00000000000016eeedb3f367daaee93334188db877fb01cd0282b990f60812b3",
+ 0
+ ],
+ [
+ "00000000000001daf3bd8306b6f6899af8aa656d87ac2aa37d493fdcb0cb3000",
+ 0
+ ],
+ [
+ "0000000000000390b86892ad0bed9b520783056961cad7362ace8049aa00471c",
+ 0
+ ],
+ [
+ "00000000000002105d01b4de7d3e3ada9c757a239151d50b5dd193e3951a23cc",
+ 0
+ ],
+ [
+ "00000000000002362fa802df308201a4b1fff2fd8a91892915a46f5d54098ff4",
+ 0
+ ],
+ [
+ "00000000000004fb8aa6c6aecb64b9d8d7e691a6cd56fad69fc5278b9e8d98cb",
+ 0
+ ],
+ [
+ "00000000000000ce3bd9752b2508ddae1ee71332e905163a3c0d7e10b8c472f7",
+ 0
+ ],
+ [
+ "00000000000002d0d8520982f15a45d4a405334c61886b6d13d95843386af647",
+ 0
+ ],
+ [
+ "00000000cafd25502ad67d5d409edfc98f5bbd3173e86e085c69658d58da5f70",
+ 0
+ ],
+ [
+ "00000000b01e0675317a29a07731ea092fa029016a40ed8bb4fc17cde50eda05",
+ 0
+ ],
+ [
+ "000000002676805396ed2883ccc8ad401aa0a974627559fbae2416ba5c54999c",
+ 0
+ ],
+ [
+ "0000000000030ab759158f3d425824228dc5c91f32db91d404bee29ee3a41878",
+ 0
+ ],
+ [
+ "00000000000da1c8040ec08e7490fb201ca1fb3571f29c0efd3351ae197d3017",
+ 0
+ ],
+ [
+ "000000000004e3cba890c16ffc7d1c019d4ab88afa39315164e1b08b8e6a9330",
+ 0
+ ],
+ [
+ "00000000000bdcfb630b43977be44529e54daa02d199014a0967deac669bd060",
+ 0
+ ],
+ [
+ "000000000007254038f9c621d6df0d9fbd90b5697e4170cd6090daaf579f3790",
+ 0
+ ],
+ [
+ "000000000002263e27ea1cec943632bf469a28b067f0bfde3b9a6b48540981b4",
+ 0
+ ],
+ [
+ "000000000000f194a8d17e683d17f222d23a9032f034d4dc4497263fd785dfa0",
+ 0
+ ],
+ [
+ "00000000000036e359b7b07044e3cd5b132a3c72501a0f3f9ccde167f5316bba",
+ 0
+ ],
+ [
+ "0000000000000b10e98a90e0fd1ffbf7d5fc5a76e8e6e960c6fb158711af6f48",
+ 0
+ ],
+ [
+ "0000000000000104e1e4303b8dae78389bb4e6c38f3eb3fe42aec6464bd5c397",
+ 0
+ ],
+ [
+ "00000000000000bde368a635921f5ad25aeb4b784651de24d624cf20c27691c7",
+ 0
+ ],
+ [
+ "0000000081a626a33cff134e7e56dc0f0a67b1735c96256774885d5d095807c0",
+ 0
+ ],
+ [
+ "0000000055d357cdf39130eb767f416101e79025515906bea528f43cb6446920",
+ 0
+ ],
+ [
+ "0000000012558b30f9c1a156fd80b02451e8dfcc7fe0350fb4adeeb84951a0a6",
+ 0
+ ],
+ [
+ "000000000001a4868924fc7cca0334ffc4dd49c07fb841c1da059a7c219bdf95",
+ 0
+ ],
+ [
+ "00000000000010086bd2bba88c71b08cfc7e24183d610a2803e6d382049d52c0",
+ 0
+ ],
+ [
+ "0000000000018c83992fe05d820b097228de93787e3f59e65cb89ad4c385e364",
+ 0
+ ],
+ [
+ "00000000000023ab80324770ff4c6802d09e5e1e7de78d2a8e64783904d47f19",
+ 0
+ ],
+ [
+ "000000000000287fa294ea557835d8c98bfe94c4d8b18d5b10f1b62d68957113",
+ 0
+ ],
+ [
+ "000000000001d842f5a0dff13820ba1e151fd8c886e28e648a0be41f3a3f1cb3",
+ 0
+ ],
+ [
+ "000000000000906854973b2ec51409f0b78b25b074eef3f0dbb31e1060c07c3d",
+ 0
+ ],
+ [
+ "00000000000009e694e22b97a4757bffef74f0ccd832398b3e815171636e3a85",
+ 0
+ ],
+ [
+ "0000000000000594b95678610bd47671b1142eb575d1c1d4a0073f69a71a3c65",
+ 0
+ ],
+ [
+ "00000000000002ac6d5c058c9932f350aeef84f6e334f4e01b40be4db537f8c2",
+ 0
+ ],
+ [
+ "00000000000000c9a91d8277c58eab3bfda59d3068142dd54216129e5597ccbd",
+ 0
+ ],
+ [
+ "0000000000000051bff2f64c9078fb346d6a2a209ba5c3ffa0048c6b7027e47f",
+ 0
+ ],
+ [
+ "000000000000df3c366a105ce9ed82a4917c9e19f0736493894feaba2542c7cd",
+ 0
+ ],
+ [
+ "0000000000007c8006959f91675b2dbf6264a1172279c826ae7f561b70e88b12",
+ 0
+ ],
+ [
+ "0000000000015ab3720de7669e8731c84c392aae3509d937b8d883c304e0ca86",
+ 0
+ ],
+ [
+ "0000000000016d7156ee43da389020fb5d30f05e11498c54f7e324561d6a6039",
+ 0
+ ],
+ [
+ "0000000000009c9592f83d63fe39839080ced253e1d71c52bce576f823b7722a",
+ 0
+ ],
+ [
+ "00000000003dee6b438ddf51b831fbedb9d2ee91644aaf5866e3a85c740b3a99",
+ 0
+ ],
+ [
+ "00000000000155f5594d8a3ade605d1504ee9a6f6389f1c4516e974698ebb9e4",
+ 0
+ ],
+ [
+ "000000000001e21adfc306bf4aa2ad90e3c2aa4a43263d1bbdc70bf9f1593416",
+ 0
+ ],
+ [
+ "0000000000008218e84ba7d9850a5c12b77ec5d1348e7cbdfdcb86f8fe929682",
+ 0
+ ],
+ [
+ "00000000000054fb41b42b30fff1738104c3edca6dab47c75e4d3565bc4b9e34",
+ 0
+ ],
+ [
+ "0000000000002763b825c315ba35959dcc1bd8114627949ede769ac2eece8248",
+ 0
+ ],
+ [
+ "00000000000007437044da0baed38a28e2991c6a527f495e91739a8d9c35acbb",
+ 0
+ ],
+ [
+ "000000000000032d74ad8eb0a0be6b39b8e095bd9ca8537da93aae15087aafaf",
+ 0
+ ],
+ [
+ "000000000000006d4025181f5b54cca6d730cc26313817c6529ba9ed62cc83b3",
+ 0
+ ],
+ [
+ "000000001c3ad81ffea0b74d356b6886fd3381506b7c568f96c88a78815ede09",
+ 0
+ ],
+ [
+ "000000000140739d224af1254712d8c4e9fb9082b381baf22c628e459157ce49",
+ 0
+ ],
+ [
+ "000000000306491c835f1a03c8d1e17645435296d3593dacba8ab1a7d9341d38",
+ 0
+ ],
+ [
+ "000000000002b383618b228eb8e4cfcf269ba647b91ac6d60ddd070295709ad1",
+ 0
+ ],
+ [
+ "000000000000c90fc724a76407b4405032474fc8d1649817f7ad238b96856c6a",
+ 0
+ ],
+ [
+ "0000000000002d5a62b323a5f213152dd84e2f415a3c6c28043c0ccaaddb3229",
+ 0
+ ],
+ [
+ "0000000000008c086a21457ba523b682356c760538000a480650cd667a29647a",
+ 0
+ ],
+ [
+ "00000000000007c586d36266aa83d8cc702aa29f31e3cc01c6eeac5a0f5f9887",
+ 0
+ ],
+ [
+ "0000000000013bf175e35603f24758bf8d40b1f5c266e707e3ba4de6fae43a7f",
+ 0
+ ],
+ [
+ "00000000000096841c486983a4333afb2525549abe57e7263723b9782e9cfef1",
+ 0
+ ],
+ [
+ "00000000000012dfd7c4e1f40a1dd4833da2d010a33fc65c053871884146c941",
+ 0
+ ],
+ [
+ "0000000000000b47eb6bc8c6562b5a30cefcf81623a37f6f61cc7497a530eb33",
+ 0
+ ],
+ [
+ "0000000000000021ca4558aeb796f900e581c029d751f89e1a69ae9ba9f6ebb3",
+ 0
+ ],
+ [
+ "00000000000000a5bf9029aebb1956200304ffee31bc09f1323ae412d81fa2b2",
+ 0
+ ],
+ [
+ "0000000000000046f38ada53de3346d8191f69c8f3c0ba9e1950f5bf291989c4",
+ 0
+ ],
+ [
+ "00000000658b5a572ea407ac49a1dccf85d67d0adfc5f613b17fa3fff1d99d51",
+ 0
+ ],
+ [
+ "000000005d6be9ae758c520b0061feee99cd0a231f982cc074e4d0ced1f96952",
+ 0
+ ],
+ [
+ "0000000001aa4671747707d329a94c398c04aaf2268e551ac5d6a7f29ffd4acd",
+ 0
+ ],
+ [
+ "0000000004b441b97963463faca7a933469fabfa3e7b243621159e445e5c192a",
+ 0
+ ],
+ [
+ "0000000002ce8842113bc875330fa77f3b984a90806a5ec0bb73321fef3c76c6",
+ 0
+ ],
+ [
+ "0000000000019761bf9a1c6f679b880e9fb45b3f6dc1accdbdcfce01368c9377",
+ 0
+ ],
+ [
+ "0000000000008a069efd1a7923557be3d9584d307b2555dc0a56d66e74e083e1",
+ 0
+ ],
+ [
+ "000000000001c14cec52030659ef7d45318ca574f1633ef69e9c8c9bd7e45289",
+ 0
+ ],
+ [
+ "0000000000009cfccb8a27f66f1d9ff40c9d47449f78d82fee2465daca582ab7",
+ 0
+ ],
+ [
+ "0000000000007f30cfae7fbb8ff965f70d500b98be202b1dd57ea418500c922d",
+ 0
+ ],
+ [
+ "0000000000002cbd2dbab4352fe4979e0d5afc47f21ef575ae0e3bb620a5478a",
+ 0
+ ],
+ [
+ "000000000000017a872a5c7a15b3cb6e1ecf9e009759848b85c19ca6e7bd16d2",
+ 0
+ ],
+ [
+ "00000000000001ade79216032b49854c966a1061fd3f8c6c56a0d38d0024629e",
+ 0
+ ],
+ [
+ "0000000000000090b8dfe4dde9f9f8d675642db97b3649bd147f60d1fc64cd76",
+ 0
+ ],
+ [
+ "0000000000000109ed5f0d6fc387ad1bc45db1e522f76adce131067fc64440ec",
+ 0
+ ],
+ [
+ "000000000000003105650f0b8e7b4cb466cd32ff5608f59906879aff5cad64a7",
+ 0
+ ],
+ [
+ "0000000000000113d4262419a8aa3a4fe928c0ea81893a2d2ffee5258b2085d8",
+ 0
+ ],
+ [
+ "00000000000000f15b8a196b1c3568d14b5a7856da2fef7a7f5548266582ff28",
+ 0
+ ],
+ [
+ "0000000000000034fb9e91c8b5f7147bd1a4f089d19a266d183df6f8497d1dff",
+ 0
+ ],
+ [
+ "000000000000005e51ad800c9e8ab11abb4b945f5ea86b120fa140c8af6301e0",
+ 0
+ ],
+ [
+ "00000000000000e903f2002fd08a732fd5380ea1f2dac26bb84d57e247af8ac2",
+ 0
+ ],
+ [
+ "000000000015115dac432884296259f508dae6b6f5f15cef17939840f5a295c3",
+ 0
+ ],
+ [
+ "000000000029913c80e5f49d413603d91f5fd67b76a7e187f76c077973be6f8a",
+ 0
+ ],
+ [
+ "00000000002e864e470ccec1fec0ca5f2053c9a9b8978a40f3482b4d30f683a9",
+ 0
+ ],
+ [
+ "00000000001ccf523df85df9abdb7c5bbad5c5fcbd12a4a8eb4700de7291f03b",
+ 0
+ ],
+ [
+ "00000000002aa81027df021e3ccde48dff6e7f01a4aba27727308f2ce17f2f1a",
+ 0
+ ],
+ [
+ "000000000015a577d71d65bde7e8f5359458336218dc024584f7510b38dc1259",
+ 0
+ ],
+ [
+ "00000000003aef1877bcc6817cac497aeb95af3336ba2908e8194f96a2c9fc29",
+ 0
+ ],
+ [
+ "00000000000ccd42d542ddca68300ec2a9db2564327108234641535fd51aa7f3",
+ 0
+ ],
+ [
+ "000000000000a2652b2e523866f3c4d5c07dc1c204d439b627f2ab2848bfa139",
+ 0
+ ],
+ [
+ "0000000000002c065179a394d8da754c2e2db5fed21def076c16c24a902b448d",
+ 0
+ ],
+ [
+ "000000000000175a878558186e53b559e494ce7e9f687bf0462d63169bfcce03",
+ 0
+ ],
+ [
+ "00000000000007524a71cc81cadbd1ddf9d38848fa8081ad2a72eade4b70d1c1",
+ 0
+ ],
+ [
+ "0000000000000159321405d24d99131df6bf69ffeca30c4a949926807c4175ad",
+ 0
+ ],
+ [
+ "000000000000016c271ae44c8dca3567b332ec178a243be2a7dfa7a0aef270c3",
+ 0
+ ],
+ [
+ "00000000000000a7d62de601cdf73e25c49c1c99717c94ffd574fc657fd42fa8",
+ 0
+ ],
+ [
+ "0000000000000052d492170de491c1355d640bae48f4d954009e963f6f9a18c3",
+ 0
+ ],
+ [
+ "000000006f5707f2f707b9ddcce2739723e911210b131da4ca1efdff581212ad",
+ 0
+ ],
+ [
+ "00000000021be68dc9c33db0c2222e97cd2c06fc43834e8f5292133c45c2abb4",
+ 0
+ ],
+ [
+ "00000000019ca3eaf7c39f70a7a1a736f74021abf885bebc5d91aa946496bac5",
+ 0
+ ],
+ [
+ "00000000006e4752fbe2627ebb2d0118f7437908a8219f973324727195335209",
+ 0
+ ],
+ [
+ "00000000038471612a0955307f367071888985707ec0e42c82f9145caed8fea1",
+ 0
+ ],
+ [
+ "000000000004604d2d7d921b21d86f2ade82ded3af33877ec59d47072023d763",
+ 0
+ ],
+ [
+ "000000000034a3e45665a8dcbb94e7a218375a5199b3f3ca2cc7b5fe151bb198",
+ 0
+ ],
+ [
+ "0000000000043fb2c2ff5db60c6d2d35a633746e8585e04a096a9b55a4787fe6",
+ 0
+ ],
+ [
+ "0000000000020d4d8735b66134c1fcdd1d3f3d135b9ff3f70968ef96c227fb75",
+ 0
+ ],
+ [
+ "0000000000004f3f4dc1fa11a6ad9bd320413b042eb599c4599a14d341f6825f",
+ 0
+ ],
+ [
+ "0000000000001e0a495d23acf46a44f8b569ada39ac70730da5e9109871b77e9",
+ 0
+ ],
+ [
+ "00000000000002257a08acca858f239fabb258a7cc1665fc464f6e18e9372d32",
+ 0
+ ],
+ [
+ "00000000000002845d416fbfa05a5d40ba5ba5418a64f06443042a53cf1fd608",
+ 0
+ ],
+ [
+ "00000000000000fee91a2ae8b8d1bb9a687c9b28b0185723c8ff6ffdac2e9ce4",
+ 0
+ ],
+ [
+ "00000000000001d6874b4d88e387098c0b7100ff674d99781fc7045a78216a15",
+ 0
+ ],
+ [
+ "00000000000144a03e701c199673d72fc63766bcf0cdaf565f4c941c7ef72971",
+ 0
+ ],
+ [
+ "000000009b6cc4d8aee22cca6880e4d7bb30bff2851034ad437d63d3a7278de7",
+ 0
+ ],
+ [
+ "0000000023e998d64618475e31b4aee9d83d2bc32cb6d062aa97c0b4651fed08",
+ 0
+ ],
+ [
+ "0000000000036f4bf6b42a7776a97872fa24362064c5bc4bc946acb70ab6fbf4",
+ 0
+ ],
+ [
+ "0000000001e2252455ffd0cf0b4109ace996a0d2a03999f5cc5c5e08fb6130ac",
+ 0
+ ],
+ [
+ "0000000000002713db42d53f0c2d86c904f4e0338652acc1cbda953c530a15bb",
+ 0
+ ],
+ [
+ "000000000001b075f9ccc604a50326732f5d42373c4a831978be0e2d830cac75",
+ 0
+ ],
+ [
+ "0000000000000bfa7d93c6b36298b933b1a652c95ee9f0de4151e007f3180391",
+ 0
+ ],
+ [
+ "000000000002c60a0af1cfeb9c26c60970b354897fd0a94c8e5c414d0767b06b",
+ 0
+ ],
+ [
+ "0000000000001f2d9462507a9408859fb0b5f97013d6b4577337b0382340c5aa",
+ 0
+ ],
+ [
+ "0000000000000b7428e0d3c6c7fd2df623a74125db4989b1c61c78eeed1bcde5",
+ 0
+ ],
+ [
+ "00000000000002e8b4f1fa041a37515c1b76d59994792f1c772c9a4993c194dc",
+ 0
+ ],
+ [
+ "0000000000094e70c0cf5185b480542a1faa8392a3f2f7f583d91e033856d7ce",
+ 0
+ ],
+ [
+ "000000005b036d8c18ed5d1219e4137bd71438c9b1ba7ff4d10a626e9a7bcc98",
+ 0
+ ],
+ [
+ "0000000008745d4a943e958f5cb5084646c0fe1cae57eeab666c3ad0d4ff1dec",
+ 0
+ ],
+ [
+ "00000000000f8c5b3455e540d074b5c71709e37f8950975953798d27bdc701fa",
+ 0
+ ],
+ [
+ "0000000000050885884f7ac233bb174cf7b33c037f81907f7766afe9d0ad9091",
+ 0
+ ],
+ [
+ "000000000002d7cd1043ccd0581a47d6fdf82a7cf1646b61495f917a48ebeb5c",
+ 0
+ ],
+ [
+ "000000000003a2b3e3d7ef47829db1672bfd79e49f32ef3a04ec7c4df355392b",
+ 0
+ ],
+ [
+ "0000000000032a6c7e5bc3878c1815bc6759594a4736638fdacaa5642be3e649",
+ 0
+ ],
+ [
+ "000000000001386a3904f0ba4f25dc7ace09b67a6fe8977e7aecc55813fa9ac5",
+ 0
+ ],
+ [
+ "0000000000003fe030a2231da87076679c1d38d323bf56b45ceb49a5128fb4b1",
+ 0
+ ],
+ [
+ "000000000000147cd3b6195c6a727cd4fe6b3a879d7934e52bf29020ed9c6fcc",
+ 0
+ ],
+ [
+ "00000000000003ed5a0a7176f3f1b3ed26510045af2860e5b6313b358774fbad",
+ 0
+ ],
+ [
+ "00000000000000c2952ac8a580895ac13799a9c29badb6599bc4a86c1fc83b6e",
+ 0
+ ],
+ [
+ "0000000000000056f49d6f7b8243eecf6597946158efe044b07fd091398e380d",
+ 0
+ ],
+ [
+ "000000000000006b039683c36b18ec712346521edce4dc5b81cdaf6475d89bd7",
+ 0
+ ],
+ [
+ "00000000000000525de83fba2439549ef0ed78d6d08516a0513abb972b0fca95",
+ 0
+ ],
+ [
+ "000000000000006c5403ae9c42acf37362885c75c1a71a6b7fe20f9cfc5304a7",
+ 0
+ ],
+ [
+ "000000000000006f881a62bc5ec9d4c4da83ddc6619a7eee82617e26e2c7ef3c",
+ 0
+ ],
+ [
+ "000000000000012941300197c5b6627a66f9cf48ae9c6791b36c63c0218a1be9",
+ 0
+ ],
+ [
+ "00000000000002cd7ec2e00992a4dc6c5e0a56cfbc19b5afa9730bd94f174b5b",
+ 0
+ ],
+ [
+ "000000000022e09ee2ee7b3fd223cb9ccfe11058cca5ad0c705fe5a0c26b28dc",
+ 0
+ ],
+ [
+ "0000000007d35ebaf81412d40d1224bdc5792bfbc70827c09f05dc5fb168e67f",
+ 0
+ ],
+ [
+ "00000000328e1b1aecf68947ad53fb11c58a383704ddbb8b29704669e22225bd",
+ 0
+ ],
+ [
+ "000000000003d3b3f171fd10fda1be9d4464b1438bb9443081c2c224a047cc4e",
+ 0
+ ],
+ [
+ "000000000001e3c5dcea0586d3c8f69c0f35658fae283d29f64df9b5301bc721",
+ 0
+ ],
+ [
+ "00000000000ce5f3757a0cab09a8cb131b3f2c63303375ad1c84fe423866d33f",
+ 0
+ ],
+ [
+ "00000000000ca01b96070fb643bcebbc862cff4da78dcd52de1418c940d4f466",
+ 0
+ ],
+ [
+ "0000000000006eb74e5036cf42888759c4ebf91a5eb128463e60ae9ab02876a3",
+ 0
+ ],
+ [
+ "000000000003aae0765dfee956b322477d786a2cde617ff073e0bc4eeaf7c252",
+ 0
+ ],
+ [
+ "00000000000033421d804b4bc0f7dc61715d2fc0cc2a98904ff5e1f9ef909010",
+ 0
+ ],
+ [
+ "0000000000002a24b916b5f03bd47250276ad32f08a1684334c7f181b0b7a055",
+ 0
+ ],
+ [
+ "00000000000002a7399ec806255c4ae63d7583001bbde70e2038e9b90fb824f4",
+ 0
+ ],
+ [
+ "00000000000000ec89aaa13c7222b3ec787a487cdc7a17c1ee87ce313e6ed4d3",
+ 0
+ ],
+ [
+ "00000000000001564cf9db3397bd0983a68f450d5b7e59824339fe1d46ba1c75",
+ 0
+ ],
+ [
+ "00000000000e932953388774b6b3492d8756f936d74fda1d33eace33538fb0bb",
+ 0
+ ],
+ [
+ "0000000084c2d56f703e72f6ad637105409552792ee482bbc14376cfb29c30d9",
+ 0
+ ],
+ [
+ "00000000392f30ba333fac2e4937e162105ba2b20fe953848b1a4c004f460223",
+ 0
+ ],
+ [
+ "00000000000842b42c56e4dc573efd9b6b6864dba81730c4f95b837d52078ad5",
+ 0
+ ],
+ [
+ "0000000003e4cca12f6109687fcccfc5c3827bf3bca2487096fec0293b4b351e",
+ 0
+ ],
+ [
+ "00000000007b7eece3ebbf77ed583a711c8427284ea9b556ec67efd14e7f5d90",
+ 0
+ ],
+ [
+ "000000000002c0e026657401be7998fce1618869ec073a49ac935a15d16c5741",
+ 0
+ ],
+ [
+ "00000000000cf19ef67151f6d06b426371dfa63d9d2bbd6024cca520cf4d96b4",
+ 0
+ ],
+ [
+ "0000000000019a6ef183423833a4347d77e8687b4fc83a85f4c98c579631acbe",
+ 0
+ ],
+ [
+ "000000000000a292b9ff43becd4770243d2750e2b3c4e81a6ed79b8abd2f5052",
+ 0
+ ],
+ [
+ "000000000000280db4a9a31097024bc81f0358ba624f1f8dd83a2362a156a817",
+ 0
+ ],
+ [
+ "00000000000009b17b295d898cda8899ce547183fd63fa901b9f502aed00c45d",
+ 0
+ ],
+ [
+ "0000000000000013f5c40f6b0e7e8fe854045135564a4df6ff4ca736861d7ea8",
+ 0
+ ],
+ [
+ "000000000000c39ffca7d1daad0d4f8af9ee108443bb1b4352cd740fd8297aef",
+ 0
+ ],
+ [
+ "000000000002f42ee90d7d459393eb90e2ea5a3ed292394ce1dc5f7a42d66ce0",
+ 0
+ ],
+ [
+ "0000000000010d6bd31805e0a9b8629192c0ad704641d2b08c28865052bbf469",
+ 0
+ ],
+ [
+ "0000000001015f5067612dc0d681d71b33d278c50ca88d7756322ab90f753290",
+ 0
+ ],
+ [
+ "000000000003dadd324301ee6157c29e7aa9f120edefaf05369d849510e6d60c",
+ 0
+ ],
+ [
+ "000000000000a62107ea11c5db9929d819181d8903624e9088b8700d1dc66ea7",
+ 0
+ ],
+ [
+ "00000000000022b91e1b652f626cd3a81bfb2ff70717ace53c488dd45c75fcbb",
+ 0
+ ],
+ [
+ "0000000000002845027a6a08c436c6e99aa8af0f7c744a722fd598ba0f66f4cb",
+ 0
+ ],
+ [
+ "000000000000ae5347baecbcb3cd01265f0e52c8819f830dcfc6dafa1ec4327a",
+ 0
+ ],
+ [
+ "0000000000008dd3169522647ae90ca0a3acc405f0e8c2b53dab013433708921",
+ 0
+ ],
+ [
+ "00000000000023abea5dd709951fb1fa5c34a75670ddc7eea46d2d23c6033669",
+ 0
+ ],
+ [
+ "00000000000006fe20edd4be3beabc4432fbe410ab53466660105ced53056190",
+ 0
+ ],
+ [
+ "000000000000003f6d6889d2917ba88f6e286c156028baebf05be409e1b97ef8",
+ 0
+ ],
+ [
+ "000000000000005d871f102aaa25e60855c96c1aa8404f004db1c8bbfab341e9",
+ 0
+ ],
+ [
+ "0000000000000197fac06dd6c7f80c838b6a21f1ce72f10aa6ba0aff40c3cb92",
+ 0
+ ],
+ [
+ "0000000000000289a999cf132efbee896d8c22e2f9d1036381b00d72c41660e3",
+ 0
+ ],
+ [
+ "00000000e9f6bd4700dea0c0841272461e4e9d125b8fe2c35a2ca39f77269321",
+ 0
+ ],
+ [
+ "00000000f91f03ac1d08214a3646c2bef1878961a8c40d867254d733fd9cb2a3",
+ 0
+ ],
+ [
+ "000000003d42ef351c6a1fb5e2d43d1a28ca095052be35ad9bb901b097c667c8",
+ 0
+ ],
+ [
+ "000000000014b426a9844698b6369c0e2befe4e369f1dd01c157dbdd472c9136",
+ 0
+ ],
+ [
+ "000000000016dfa525db05b9db92a080e0da65a4a0b15e538649eb4c0c670cf4",
+ 0
+ ],
+ [
+ "0000000000027a82eb5b1ab46a276a9aa19e3a1e52e2328c07a50db314664148",
+ 0
+ ],
+ [
+ "000000000026945c53ba1f9b0c34f9e502f3aa64c9979ce583b93daf347d2292",
+ 0
+ ],
+ [
+ "00000000000f64a42d38e16119aa724e6d859d8b7ed2964bd0929a226e57c838",
+ 0
+ ],
+ [
+ "0000000000011bee42dca16315be14fd0be451e4385c787a66c7dc6c0a498ce2",
+ 0
+ ],
+ [
+ "0000000000007fcace99545546c5ee4df862e21840543865ad0944ca7b82baf7",
+ 0
+ ],
+ [
+ "0000000000003b3a9be8e418e11db77aa16dbf9f04a9b43b34466e7b41520fa2",
+ 0
+ ],
+ [
+ "00000000000004ae741f8cd7f6f20231f8be6b89946e50339f0089a2e5c6d4d6",
+ 0
+ ],
+ [
+ "0000000000000379b21385de297e65a62e4d15ee27fbf1e3b4fa7a46b4a274ba",
+ 0
+ ],
+ [
+ "000000000001fd6b7db603c305be360c602800e5d9068bd65bae111b4561d5ab",
+ 0
+ ],
+ [
+ "000000003925c7eb3144eb77e7891a607152b662b161cd4a052e2a5689c4b694",
+ 0
+ ],
+ [
+ "000000000000a8476194924cd6612277821149e22f7326a054c09c7d55b8a9d5",
+ 0
+ ],
+ [
+ "0000000009ddc12332eb5903b89ddfd116bfd9b300c4d70821e749a302fa438b",
+ 0
+ ],
+ [
+ "0000000000028fe3bfc47a9ad8a71c90fa3edea0c1d04f823c5a9d8674b9d1c5",
+ 0
+ ],
+ [
+ "000000000000075849c07342e632fa3f2b4e137de35703e91c62cb568a8583ea",
+ 0
+ ],
+ [
+ "0000000000001100406d8447ce19989346956134e2dabb87f93ff1b32208dc21",
+ 0
+ ],
+ [
+ "0000000000006a8a2fd9d16a22f28523940811b3c4f179f888249b6f5f19c708",
+ 0
+ ],
+ [
+ "000000000001af7c8a48d294945d937c3f1ab297617bab1a0eb1d9a40e543139",
+ 0
+ ],
+ [
+ "00000000000040eafb8f54cb988a19d0370379be0b2917787e640720677ba6de",
+ 0
+ ],
+ [
+ "000000000000025f7bc6cb5759f267fd649620c69f6518213729bb6aeb4d98d3",
+ 0
+ ],
+ [
+ "0000000000000217a8588f1af88d2f73a96a658f0aea62de5c53b5b348346456",
+ 0
+ ],
+ [
+ "00000000000001b8aa8353bbafb6f47125f67a711c0a2a7a00bfebff5a8df093",
+ 0
+ ],
+ [
+ "000000004ca77c8921259d7da52f341526df3f34edb62e3e2888b7ce42b8c29f",
+ 0
+ ],
+ [
+ "000000005c8253a86af2492291e888d78d0a69a7a657a221e59b23eb6291fcff",
+ 0
+ ],
+ [
+ "0000000000fba14ebb3757a9348a05b07ec207b25aaffeac4118237e665fc566",
+ 0
+ ],
+ [
+ "0000000008f01a3c024cb6d1814e54659c72b17e34e2b60fd35af2184b6bd3ea",
+ 0
+ ],
+ [
+ "0000000003da1325f0d607889753f3a7214c3e559b9834c6f0e37bd52e14eaec",
+ 0
+ ],
+ [
+ "0000000000d303f0b50fc25ea141ad3c26d0dfe61fa4cfcc6875edbcef902163",
+ 0
+ ],
+ [
+ "00000000002131de3bcff721c93c169e34450054c18fc02cd5a8e08c7c3fd567",
+ 0
+ ],
+ [
+ "00000000000c69cdb751a4ef5f527ae244909ddfda10a4caed4d6f8dd44e51fe",
+ 0
+ ],
+ [
+ "0000000000024819bfbc99fd2032441181dcb2456ada1d047c4b6b7829be62a0",
+ 0
+ ],
+ [
+ "00000000000077021c5164bc1014b24abd321f160bb914a1257a86645f923385",
+ 0
+ ],
+ [
+ "00000000000038e149b42e964bdeb10f01fbbfd38ce57ec25eb3fdfb712cf9b0",
+ 0
+ ],
+ [
+ "000000000000047dd3d1ce9862add6979aa622a7cb2141b4c6ec569b172dd776",
+ 0
+ ],
+ [
+ "0000000090c401521295d1040e0f9b6cb65da914085bb9346e60477837dab234",
+ 0
+ ],
+ [
+ "00000000f36784781eaf4b0d3ef92525b6cf55e910c782bd4f355b71ee40dc36",
+ 0
+ ],
+ [
+ "000000001d3848f040d48696a9e258798bea34969e810ad01e8092183f201dfd",
+ 0
+ ],
+ [
+ "0000000007658642f1e8ac45feec2766358f425030b14ad824f3a6df30b9eb15",
+ 0
+ ],
+ [
+ "00000000028e5b819d9e197b1d3f1246a2a6990d8e2360371dbf258c2c5861fb",
+ 0
+ ],
+ [
+ "00000000002a8dbd19a807d955c7d01962fea32f5ae027345121176ac10c20f4",
+ 0
+ ],
+ [
+ "0000000000144908febd5cbacd1d9b828817f0350211be3248a1ec2d3ac3e251",
+ 0
+ ],
+ [
+ "00000000000a302f19d696c7be172c6ac92ec2adf956417bba482d3e5285e5d7",
+ 0
+ ],
+ [
+ "000000000000a289eb62cae8c41644d7c9de31148f711744aa5409164b90d6e3",
+ 0
+ ],
+ [
+ "000000000000036a6f6002c633b6be318745d2f2ff1520daa6a49db7649bca67",
+ 0
+ ],
+ [
+ "0000000000000293db488f4a3c7289489664e6e7e1ec917dc58c83ec828a4730",
+ 0
+ ],
+ [
+ "0000000000000e24d4ce3b9247d6316791438ab82ea755e788112bb9729730cf",
+ 0
+ ],
+ [
+ "00000000000003a18b92493908ebe4ccecf24bfeda95bf3b8a026e3c01af116a",
+ 0
+ ],
+ [
+ "0000000000000007a2b7ba9dd58c20651b477daf83df5a7ac24b856b22f1fb25",
+ 0
+ ],
+ [
+ "000000000000000ce321e0271dd532a6ce58737151baa84a77a585df614c2ab6",
+ 0
+ ],
+ [
+ "000000000000004ebec3379d6a8569295a2d0a0c0e0c815d2b01803315032185",
+ 0
+ ],
+ [
+ "000000000000001bb9ed28d9b0a70fee0b6d42f91f3db53f2086eef4daabce30",
+ 0
+ ],
+ [
+ "000000007c5711c573d147a6fae21faf529c039220c97dfe2ba96e732d88fa89",
+ 0
+ ],
+ [
+ "000000008e5a5e820d1a10dbeecf6f6df3bf7ab56e46eec275d8ca1a52e86b68",
+ 0
+ ],
+ [
+ "000000003fa06ace5db33de18cf03b0c56d4e62cdaf8ab533919953c22bffaf1",
+ 0
+ ],
+ [
+ "000000000000e6442b0c74fa811319edf2edd5f8d9b2e3ee831b4bdee644fbd0",
+ 0
+ ],
+ [
+ "00000000011d0c3f98e9c3db6b51468be632bdef0c47f5e45871b771e5b0bc57",
+ 0
+ ],
+ [
+ "000000000000e3c0978d872ed3b3a43f6f319995459105159b5f4e92143d40d2",
+ 0
+ ],
+ [
+ "000000000000cdf25c3e15601dcb798c6cf8d2dd89002a4e046b746be6b87fa0",
+ 0
+ ],
+ [
+ "000000000000521507052d13f4fac6c01c0099466720bea95c2e9349aef7fa5f",
+ 0
+ ],
+ [
+ "00000000000064823750f1a6b7cd1748dfcc73376086cfdba987d2a36fcddb71",
+ 0
+ ],
+ [
+ "0000000000000b4a41be0612f47a58efb899dc1cc0965c1c1fac89e1ea69f587",
+ 0
+ ],
+ [
+ "00000000000010aab857bf7d475d9a594dca8b1144597a9e69c70f20fdd20b4f",
+ 0
+ ],
+ [
+ "0000000000000c264f193e8d5099f2c20c08fdf9e5ca9006fb53778c0d8eb869",
+ 0
+ ],
+ [
+ "00000000000002adcce72a5cce517f1afc33c765927b77ccbce5cdc6f5f68e45",
+ 0
+ ],
+ [
+ "00000000b179a6096a58938311b3b8cc4479ccdf3909667a58598acc4ebd0192",
+ 0
+ ],
+ [
+ "000000004e86c06d23b8a4c20e6cb5a4c51cad24fca30e41695f8ad00852a88e",
+ 0
+ ],
+ [
+ "000000000bafa134d62d9df490ffdbc1f2b86b4373b86c079c5b730034aad214",
+ 0
+ ],
+ [
+ "00000000033e9b623ca1d89418114f63af55e042dafbfe97952e7a5fe7a3ebf4",
+ 0
+ ],
+ [
+ "000000000119025b6c9bbc3390708b1a77e85eda69fcb79666418ac2cb874a17",
+ 0
+ ],
+ [
+ "000000000000feafbf3a525a1dd7950fa53f7df1b0210e79337ce588d35a8b9a",
+ 0
+ ],
+ [
+ "0000000000007044088a1cc9ddc0c3779c0e156dee10fa15a760897ed4249f8f",
+ 0
+ ],
+ [
+ "000000000001a10e8b1ad577278f946252298b49b74ac9db70ea80c0a9c12db3",
+ 0
+ ],
+ [
+ "000000000001281354a7d86b3c750681283276c0bdde2b18c38d8354138ca4e1",
+ 0
+ ],
+ [
+ "0000000000000398b17fcd5d4d59ccb31d642f7b60c2a4d4d2aa7239ebc0efa9",
+ 0
+ ],
+ [
+ "00000000000021a571a2c475115fe723b593633efb85bf0ec0f7d67b780e70c3",
+ 0
+ ],
+ [
+ "00000000000002d1506c82becd7b480c85402d27f23a1248cfa128b7a8c009a6",
+ 0
+ ],
+ [
+ "00000000000001978f804f5cf8e4a0dc0c454fce0f0e2614510b8eae6e504b2e",
+ 0
+ ],
+ [
+ "00000000000001c4558889a43ac35208f502bccd9d38c741571723e9d79bcc26",
+ 0
+ ],
+ [
+ "000000000000005c782bbbc75358216e1ffc37973cd43a474b87dfbac4c61fab",
+ 0
+ ],
+ [
+ "0000000053bffe3e3db3672c5f050fa54239f93833ec5c38af92e83dec71a9fc",
+ 0
+ ],
+ [
+ "000000000001362fd5182f1cbfc1981937cd67ba54bc7b6d7f0a68f94e369f0a",
+ 0
+ ],
+ [
+ "000000000386ae84caa25e9dfa7816594b7c30a079e340bfcd951be2b5c092b2",
+ 0
+ ],
+ [
+ "0000000003cc09a351d647c0e12063d45b20e6f99c27c18ea62342b9d246581d",
+ 0
+ ],
+ [
+ "0000000002527c4756350bafee88786cd7ea27bc802f482c4e50cafc547ff9f7",
+ 0
+ ],
+ [
+ "00000000003d7288f44aa0b725af7816d2d333e118de12c390423d641139d5d5",
+ 0
+ ],
+ [
+ "000000000008c0a0fadcfbe27a880ce9c387425d3a2c6b06c1a599e4ce51ec92",
+ 0
+ ],
+ [
+ "00000000000158ab2486a8f1251c5c94502763ced9eb85847bb9d2eb476b515a",
+ 0
+ ],
+ [
+ "000000000000c817e5775378accf08412657e2557d2895df0fbb8475b5e190ba",
+ 0
+ ],
+ [
+ "00000000000078d59d08215b3aecdf0e0665d3a16ae1716e408df790a3566e72",
+ 0
+ ],
+ [
+ "0000000000002208404b39b95cc20845de19b47e05e8146146056d3d9bb382ae",
+ 0
+ ],
+ [
+ "0000000000000543e9315ca8b3b72bd3590f24535e4ddc6ccb1050b607777530",
+ 0
+ ],
+ [
+ "00000000000000abb8d3ffd3cc347cee5c092dde5355a7dc5d288036a28760fb",
+ 0
+ ],
+ [
+ "000000000000008bfbcce7d768df6f4610205dcb40173e8c4c417a2325487f34",
+ 0
+ ],
+ [
+ "00000000209e49391ad09577f87d1e0ffda27d2e749fd305c51692112627c99d",
+ 0
+ ],
+ [
+ "000000000005561eb4b2e0cb8107c81617284e7bcd7d390d16a3cd5925cf42a9",
+ 0
+ ],
+ [
+ "00000000006b24215c790a371bc18c53c83ff35e2c82d459bb6240cd9615dde5",
+ 0
+ ],
+ [
+ "0000000000af315d6fbde8488d68dbd055a56d79555ed32c3ad4d70286b4df2a",
+ 0
+ ],
+ [
+ "00000000019e49bc89fcabc4050521fb8835f926a62cc10b68e9618ffc117162",
+ 0
+ ],
+ [
+ "00000000009c0dcde4e694463245e8e5e45d2897e7fa67772ce0ef37094f3afd",
+ 0
+ ],
+ [
+ "000000000005efbda8c010f29a5b81606d186459047ce4b7eacde8d9659dce97",
+ 0
+ ],
+ [
+ "0000000000051c1655579a441a7f4d543c323d482405cf1d1250c3ccb665d426",
+ 0
+ ],
+ [
+ "0000000000007f13adadd1fc6462fbc5231425b81826af4e5f0cbb0de54a5b3a",
+ 0
+ ],
+ [
+ "00000000000011e00df09353fcb53766447279b96228da0525d769f33026bebb",
+ 0
+ ],
+ [
+ "0000000000002b91e6bb56015e0e60dc650a63666aa3943058e9641d4d679fa3",
+ 0
+ ],
+ [
+ "00000000000008e4d5fbcf207583267efff33e6c8d0a5fbdaa5704aeb674fe29",
+ 0
+ ],
+ [
+ "000000000000018aeeabcb422b5b0a46cf3a5f2458125c043c5781ffafeffbf9",
+ 0
+ ],
+ [
+ "000000000000004ca501cc9138ef5fef4b7b235682b81ab9719b3cf215e94f73",
+ 0
+ ],
+ [
+ "000000000000002b5bb1c4c43059575556a0ed10099ce5095f805d3d9ae10cab",
+ 0
+ ],
+ [
+ "00000000000000018523a377832f154b2b65142098bd18dc175273c92ec938c8",
+ 0
+ ],
+ [
+ "000000000000001f58a18e73b959b3fba53f697f78aedeb431d4b5df42cc2eb9",
+ 0
+ ],
+ [
+ "000000000000002df432cf9306f7eae841b8e5b7c137c5763fd4bfb46f8a309c",
+ 0
+ ],
+ [
+ "0000000000000004941ebdbe86526e806cd13ed226daafd0dce886bfa23d2352",
+ 0
+ ],
+ [
+ "000000000000000cef306e6a9eea2d7c83d051eab259bc3f6e985de5f4ac3d6a",
+ 0
+ ],
+ [
+ "00000000816ec1fe265e8abbf9f1de03498abf8cb6cda9d29a7ec6c8518524a8",
+ 0
+ ],
+ [
+ "00000000000011d74604be4f183ed34f00c15d7218834802163c2728d0338535",
+ 0
+ ],
+ [
+ "000000000762bc47cfbc6be9269fdd65dde20d2c88719ffd90a6f2945f7c6fe9",
+ 0
+ ],
+ [
+ "0000000000002db5f8794e7dae8b50458ecd05742d4d371123252e7472573619",
+ 0
+ ],
+ [
+ "000000000002b4f1a7ae7549fa44b1b320421aa1b59e1c5ca19b086873109677",
+ 0
+ ],
+ [
+ "00000000000047b8f650640a6c7ade46b2116b25e9e31138272ed319ea5b2844",
+ 0
+ ],
+ [
+ "0000000000001256a95ea8f9b361e7eabc372d62653e9eaa0dd7fabccc61af5e",
+ 0
+ ],
+ [
+ "00000000000035948053b0e71b73d618b490cf735780b470d55d96be66abd773",
+ 0
+ ],
+ [
+ "0000000000002e1c730a9822a12a74ad4891d1083ae398430520d835487c3dd8",
+ 0
+ ],
+ [
+ "00000000000014437f28476cbe0cb6637ae1615a20e661daa90bbfc291f00660",
+ 0
+ ],
+ [
+ "0000000000002f1c8dd72d46575a0c4c98a1197313ad1450c21b190086d40a89",
+ 0
+ ],
+ [
+ "00000000000008260b87de90b439d1e8e854eafa3c271bb9218994e9d903a779",
+ 0
+ ],
+ [
+ "00000000000003476bac3afdde7dbf55d6a974817a87ce6cf4b20564916bb48b",
+ 0
+ ],
+ [
+ "000000000000006b4e74497ddcfac98e9965bfc81a5087f5f091de0d9f5118f6",
+ 0
+ ],
+ [
+ "0000000000000033ded358c7074b4e19f90fe1db1f258cf6ed9fd0227923d09e",
+ 0
+ ],
+ [
+ "00000000000000048fc3781990e18e064e3d5f3d73bab1a199d0a6519f4eda1b",
+ 0
+ ],
+ [
+ "000000000000000e1871dc667a7e6596c15a320c1f2a9a81a784aa14c62f15f3",
+ 0
+ ],
+ [
+ "0000000043dcbf92d928170baccef4bafe45f5009ad6e8c7a4fbc924bf1e659b",
+ 0
+ ],
+ [
+ "00000000a5409910e5907b6b1db12c4bd8a8063e15f39e749488fa9c035de6e9",
+ 0
+ ],
+ [
+ "0000000000008c9975f8e4192fae850a9247e14e38638ec745dd42c230137728",
+ 0
+ ],
+ [
+ "0000000000003f0df4894e1939e7ab333536e1b71a06b676419e699210a9780a",
+ 0
+ ],
+ [
+ "0000000000003771556faa6de8495f58a1b1eee15abdc71f3fee10e03e72756e",
+ 0
+ ],
+ [
+ "0000000000000f9c4ee2d147531d9381bd7fb8140d4d7be0f8f058b4017133ab",
+ 0
+ ],
+ [
+ "0000000000001833e3116f1c5f9a1c5be36918c19d6f0850842f2b54b6e674d8",
+ 0
+ ],
+ [
+ "0000000000005c08d8ac7add24fb3a01857e68b0a806951986d0f412a9ada58a",
+ 0
+ ],
+ [
+ "0000000000001f70115a7353a76b574f844b9ca9d551d346c741c005cfe2a06c",
+ 0
+ ],
+ [
+ "00000000000028fb7a7dfb30d02e93afa2cd462c8b4a12b022036714c9e6d2f1",
+ 0
+ ],
+ [
+ "000000000000386fa93b299ece82b0faab9e169c3167c671517090a2aafb3825",
+ 0
+ ],
+ [
+ "00000000000002a47ca39571cb0a79edbcdbfac91f9297776c3c2a9d8deec299",
+ 0
+ ],
+ [
+ "00000000000001cc4e0aee1cc285d411eb1cb56ac4c8fe2978e63ade53607002",
+ 0
+ ],
+ [
+ "0000000000000016bb8db548039b254578f550bb702c66eed1c441ed7fbec8d3",
+ 0
+ ],
+ [
+ "000000000000000f0cecd63a2292a5cd53a542fba78ed0d6fd3d93e9f963bca2",
+ 0
+ ],
+ [
+ "000000000000001781f58897dbcf54dc50fc2c4e5c949090a79aecd98723608a",
+ 0
+ ],
+ [
+ "0000000000000011ec668d99fd0aacd40bfc8ccf7b364d4879248e8e628bc5b5",
+ 0
+ ],
+ [
+ "000000000000001a381d52991c224ed1c6d7c4c2ee763098e022d0c04eb78381",
+ 0
+ ],
+ [
+ "000000005b04a3f6f1e7f273875826c8538d9bcee2ce58a98e61ced5bc1fb902",
+ 0
+ ],
+ [
+ "0000000049e220ce8e607b6cd231aa1f6ba7758521195d8c60afb920900ed146",
+ 0
+ ],
+ [
+ "000000002467012239401cdfe357bf8d49f4bc74be65a3145925a230dfb360f1",
+ 0
+ ],
+ [
+ "000000000bb9e52739adc38fb6924c68ed1f1962a45e75aaa18066bb7700cfa6",
+ 0
+ ],
+ [
+ "000000000001e91216fc79f80d58182417dee38dc449e592328991a344079a0d",
+ 0
+ ],
+ [
+ "000000000000ae1204d3836b126e30685d7391787820e6f8481afa7f4891d88b",
+ 0
+ ],
+ [
+ "000000000005fb7034adda5521e1deacec2a95f6ce7f65df5123742f4350a633",
+ 0
+ ],
+ [
+ "00000000000006a1154387c23fd70c2cea8036dab861d51fdf687639b2881ef8",
+ 0
+ ],
+ [
+ "000000000000fde46c03685cff7a1eff85bcb8e1604577e0bca9e3dc1cf3690d",
+ 0
+ ],
+ [
+ "000000000000d85499ad0085b4c7b9960d28e542c1ff7d8422ccf2a94fbc33f5",
+ 0
+ ],
+ [
+ "00000000000013b7bc74a6565c5c0a7d32d21567623ae8e2d18b43d5cb3c9040",
+ 0
+ ],
+ [
+ "0000000000000f26070144a87fe5ebe3676f0e6a2a2eefbf6556401293baca89",
+ 0
+ ],
+ [
+ "0000000000000302522fb697dbe69844a8cdd7696faf16a5c8a43842c2a3bdce",
+ 0
+ ],
+ [
+ "00000000000000b34c0ff70cd3b532a9cb1633896d2e683aa53827c6e0f1a25b",
+ 0
+ ],
+ [
+ "0000000000000018e983ee8b65a40d3587cab91a4fa8d29b68353778a6b7f862",
+ 0
+ ],
+ [
+ "00000000982be2494520c626711cf47b5bfff996c7f74189f9a9898a96057b11",
+ 0
+ ],
+ [
+ "00000000c1379d510ab27bb0a267a32ccf5af9e698fde308635634515496b25b",
+ 0
+ ],
+ [
+ "00000000000183573830f2976678ca06a90570c40090b7cdb52b3d3940eabffe",
+ 0
+ ],
+ [
+ "0000000004799482beb4d1622b71685fd616c923ec99a91f2b6309195814194e",
+ 0
+ ],
+ [
+ "000000000009dddcccfbcc285ce93d763201404707b6ff30740bd8e508a411a9",
+ 0
+ ],
+ [
+ "0000000000bfc6f8a9569e69ec3b57a385d81e920a3ec84d0e97a070f27107af",
+ 0
+ ],
+ [
+ "00000000000022d8d77623449f408a6dec9e7fa847c08c8c246b049c15f9d054",
+ 0
+ ],
+ [
+ "000000000006daa9307e62107c6984c2b90dae469f1fd1bb156dd7681de0eade",
+ 0
+ ],
+ [
+ "000000000002cffd12d7d9867a7837ae6b45b383c74f0563ac5709e4eb28cbf5",
+ 0
+ ],
+ [
+ "0000000000008653ff3c6a0a517ba04729eb63a0ae60c7baa975a407fd561bbf",
+ 0
+ ],
+ [
+ "00000000000005e761b3a0236e409d3ac72dc993e9cc6835f3504e62b3786d5e",
+ 0
+ ],
+ [
+ "0000000000000bb93ca70e18034211414c6769524031248b7345401ff7dcdc6a",
+ 0
+ ],
+ [
+ "000000000000004f9e54d85a4de7e13a6e64b8145713d4402927196d6c788c01",
+ 0
+ ],
+ [
+ "00000000000000d19157da447106f9827f098b01f43810b3fd97eb13488599c9",
+ 0
+ ],
+ [
+ "0000000000004db0a4da49aef66b6b47c3d2fe28152c4eed5327f36a2e1049fc",
+ 0
+ ],
+ [
+ "0000000000657115d4dbbab98df8f80baa50f6906250c62d9d634a45f2512b9f",
+ 0
+ ],
+ [
+ "00000000000dca4d6732b889fb9b824a7b80a1f509f81c52a02805ad4b008184",
+ 0
+ ],
+ [
+ "0000000000cbbcd73925dafd962774ac5457ba8ed5b60966f19915e2a92d985a",
+ 0
+ ],
+ [
+ "00000000006b6eba960b8ff4e8a84b343167ae795a05d508b36261b36a0183fd",
+ 0
+ ],
+ [
+ "00000000007a284f086b9118b29d4f1bb80b36097f1676ed923caa96f86259f8",
+ 0
+ ],
+ [
+ "000000000002065b5c30c9149183ff50f298ae4afc718002cbbd0963b07b5747",
+ 0
+ ],
+ [
+ "000000000000e2b2be6cc8930626a1cca225656f4dfd4323bc114bff9381de4d",
+ 0
+ ],
+ [
+ "0000000000014bdabb06ea628423a845a974dc4f78b841cbfd68fac5b5ae4bff",
+ 0
+ ],
+ [
+ "000000000000075561bf4f468313206932c444c56f8a0bdfe7d786674f39195a",
+ 0
+ ],
+ [
+ "00000000000029bc154a17276deb51e57fa4c67be260879e9e90c4a99e484812",
+ 0
+ ],
+ [
+ "00000000000001df7d47715a9ee239c41ea4f6927fc10513fa8e196d030a7c48",
+ 0
+ ],
+ [
+ "0000000000000190df480d22883145a8137ca5364e6370181e481f363b2dd942",
+ 0
+ ],
+ [
+ "000000000000009f95d274cfccced7b153b6bdc7a1f479086aba1af3ee51e2d0",
+ 0
+ ],
+ [
+ "000000000000000c555ee275a7c75daeea5fc8c9cc3589ce8ffc485b0e2f9c84",
+ 0
+ ],
+ [
+ "000000000000156317725ec06f7396591ade0dff87ba7e5592ae7ca8922397c9",
+ 0
+ ],
+ [
+ "00000000004bd131ef3e9b59e54b2713dc907f11ef9bcc6bf3b0d7ad791500f7",
+ 0
+ ],
+ [
+ "0000000000c373a55ff87189465e900beb21621c91cfd99f6c303a78206c17ae",
+ 0
+ ],
+ [
+ "0000000000380431e66d9fd20f35080dd6078d0506716b8a5d7e39613a98403d",
+ 0
+ ],
+ [
+ "000000000025fc2f72b36008becacae888c81811238a88086b45b28c5c394067",
+ 0
+ ],
+ [
+ "000000000033b750e7cb47cdfe186e26d38c5c461e0d8395e489c0614056041d",
+ 0
+ ],
+ [
+ "00000000001331a7e5cdf0b13e176b4f9bdabe9e5b2db5356d59cb0dfe1f0e46",
+ 0
+ ],
+ [
+ "000000000006eebd9a33e800fe588dcd34bd363357b593f6808e397893c6028f",
+ 0
+ ],
+ [
+ "0000000000028315ddfff7fb95659327af8eb64092b7d130fbcbcaa784a3d4ec",
+ 0
+ ],
+ [
+ "00000000000029d270b7cabee53577c273d9c01c87c140e5343757d3953d4b63",
+ 0
+ ],
+ [
+ "0000000000000fef07c22f210c2b7ad92b06570cdad0c82e195835e0d0378aa4",
+ 0
+ ],
+ [
+ "00000000000002b56610f192a962de55e7060686e269e3cbea07c55eaf0ce120",
+ 0
+ ],
+ [
+ "00000000000003c962e2f34b9e525dec519a6393abe3f17db6af189455cd7baa",
+ 0
+ ],
+ [
+ "0000000000000057e6976dff338a43dfa1925a5a43e97de23f01b96eb0b839af",
+ 0
+ ],
+ [
+ "000000000000002db1fe2ead78cdd2d14392b5deea255c363b5e734adc9b467b",
+ 0
+ ],
+ [
+ "0000000000000019b25e3df2e045927729efd35896654add081f6aaeebd71f47",
+ 0
+ ],
+ [
+ "00000000000000063c34230877c91acd3017621542e7ba4d9b7ef64d0b5cbe93",
+ 0
+ ],
+ [
+ "0000000000000028e023aee554cec2607f92c29e99611eb14736e2347e7bf42c",
+ 0
+ ],
+ [
+ "00000000000000369d46aac842d5505dba93ade92d5d3be1157e00ae5c047cb5",
+ 0
+ ],
+ [
+ "000000000000002077102f09a5563275c1efdbea9f6395f5146f1d6037970d7b",
+ 0
+ ],
+ [
+ "00000000fe37a092e270e704da91edd5d7c4234766916e878c8b0a02ef64870b",
+ 0
+ ],
+ [
+ "000000000039588e49d18e9ef322feab3d3cb6d14893262060890a9331b32f73",
+ 0
+ ],
+ [
+ "00000000006613c34279b3c58d8d54667ae590630aef080faa50f07ccedd3bfe",
+ 0
+ ],
+ [
+ "0000000000869e7bc5a3b34ebc2729be68c77c712317e79af584564555957b8a",
+ 0
+ ],
+ [
+ "000000000009637269a88dd5ac43497b8f27ae313211a3061470e8137d555e59",
+ 0
+ ],
+ [
+ "0000000000004a8598c4be94b043e7fbc600226fcd7547b83b64020d09a61fc4",
+ 0
+ ],
+ [
+ "000000000000100695b5f09c3a1e8c29fbe67df508481578d2948258a04d0fdd",
+ 0
+ ],
+ [
+ "000000000001bd1d57b3da64d209230bd6e314724b25a9b850e6181f49a6cd03",
+ 0
+ ],
+ [
+ "000000000000d00e36399e3a40021ac0a6adfe27973edff1ff66d6420df5a98a",
+ 0
+ ],
+ [
+ "000000000000a63aa2dcd75bf8fbccd35e3f00b684df023adbe2785eb4d8de82",
+ 0
+ ],
+ [
+ "000000000000308579c854b0c09965e609ba1c63f109f98e7cab1be5cbf364b3",
+ 0
+ ],
+ [
+ "00000000000000dba13892fba29915fb44c60e1a179d0022ca33e94c63a158bb",
+ 0
+ ],
+ [
+ "0000000000000384bfe36508812b0068cdf46b0cf1942db32cdc17a7f5dba83f",
+ 0
+ ],
+ [
+ "00000000000000f2916cb59cb479f2edcf2c2fcdee3547e772b904db17fc6d93",
+ 0
+ ],
+ [
+ "00000000000000313b0f9b9b90f1dc0194a5d54abe389817e195501274970183",
+ 0
+ ],
+ [
+ "0000000000000028d70cea4d1f3e5f3601b8c72c7e844f434972b2dcc343d3b2",
+ 0
+ ],
+ [
+ "000000000000007514deae00c442bc8c8121f2a943083c45a223ebdef390bc39",
+ 0
+ ],
+ [
+ "0000000096309ddb64473b6a32ef5814b5b197afd1de684c25b9978abb17d5bd",
+ 0
+ ],
+ [
+ "000000002201773b4ba28e91606a3925ce89fd0558a4d016565b6468d4966184",
+ 0
+ ],
+ [
+ "0000000000001cd5ff5078c2311f2f9ee10089d2630029c07febd67016245583",
+ 0
+ ],
+ [
+ "00000000001b93e7b197099b50a98462f56421e91533bbb5a5e6316d7748d271",
+ 0
+ ],
+ [
+ "00000000000001d5b12737375e9a24d85e35425534b80eb33f1f6067a3c9ee9e",
+ 0
+ ],
+ [
+ "000000000000a528259f38acd4ff2e48aa80c51e8d32d92ef22ac0319b7a4123",
+ 0
+ ],
+ [
+ "0000000000001d7a4a2d35e55b00c097a4e3cfc78b27acec46fc35e14ec3e71d",
+ 0
+ ],
+ [
+ "0000000000017069a0165b85c9a568d821ce1a23aa87fc32730ff2054d4e147c",
+ 0
+ ],
+ [
+ "00000000000163737ba8bd06de10bfd9610f897832edd1b9c00986b8db41e294",
+ 0
+ ],
+ [
+ "0000000000000f2a5f2ec3414e50228ea1a647b12b9af39005299c318adfb469",
+ 0
+ ],
+ [
+ "0000000000002ee72d7899719ed3a4f101a4bacb5a341e5de28354b668e5f534",
+ 0
+ ],
+ [
+ "00000000000000855dac9ef2f084ae3ba0609977b2d47ca7174ecc1219866c20",
+ 0
+ ],
+ [
+ "0000000000002037546232e2809ff4f0ea5416827c134afe7107d74a4939fc68",
+ 0
+ ],
+ [
+ "000000007b4f678d7e1d8b9653e1761e05d464eec7da2e65d5d3f44db1853c55",
+ 0
+ ],
+ [
+ "000000000000635cc05816a3b7d24b63cabcbfc4debbfa5410df25cf5c618351",
+ 0
+ ],
+ [
+ "00000000000064fa9ff8ad2346da5d75e029ee1d2c3d9499b2656c6a6d562c91",
+ 0
+ ],
+ [
+ "000000000000a1b3bcce8190963b2d801579b0f1fed6129ad4e21a223f52357f",
+ 0
+ ],
+ [
+ "0000000000005a78a3d8d9f33b6ece86970c75fb3b608125078d61c77bbc0788",
+ 0
+ ],
+ [
+ "000000000000d48d0c9e7933b8749939110764bc1a827f0242c43233af5512db",
+ 0
+ ],
+ [
+ "0000000000001725a132f59b477a352d478e0f6049f295fc609eed2f5e4ca3f1",
+ 0
+ ],
+ [
+ "000000000000e6308f8f026cb8b44bb0d4b15d2710446c7f3719662fe7aa5138",
+ 0
+ ],
+ [
+ "000000000000edd1062f75807cc0de78a3e19fa07763e23f744d544b1d5cce0f",
+ 0
+ ],
+ [
+ "000000000000217223dd962210aaf8ce96de32318adf53387ebf53f3b2b30539",
+ 0
+ ],
+ [
+ "000000000000021e3df79714b32722b720f916a9fbbf9102e7d753a7f242d8b1",
+ 0
+ ],
+ [
+ "00000000000002c7dc3f4aca17976008f1a3f2547bcb15b3a138af988f3e4f2d",
+ 0
+ ],
+ [
+ "00000000000000398aaeea76bd1c64e573b103ed06c11bc954e74c4c0b437add",
+ 0
+ ],
+ [
+ "000000000000001c1bf7b854488eee158545d3216e54ef714b83471d3a73c48e",
+ 0
+ ],
+ [
+ "000000000000002784b2be9801c004cefaf2b95f7626b19bec5aac1dd514fdf6",
+ 0
+ ],
+ [
+ "000000008e2cab9ea92cb5ef0416b8cbf824b37596d3a5b60406f1b3028f7faa",
+ 0
+ ],
+ [
+ "00000000104964a57ca5cbaa3008047f78705c4113033c750409a4b259936ba3",
+ 0
+ ],
+ [
+ "000000002f524bc577bcce253fde6910eba3ef06d15dd27232131b5e4345fe47",
+ 0
+ ],
+ [
+ "0000000000052c7116b0b643643af581ddca810363916a440bda2ace38add369",
+ 0
+ ],
+ [
+ "000000000000f00137a5682f26cd18abf5c6f3ef61f910c59a59d798477cb152",
+ 0
+ ],
+ [
+ "000000000002a0894c9aec54bfdcdb4a064e4507a6de62fa990177980ae17fde",
+ 0
+ ],
+ [
+ "000000000000be2b65c45a2b5d56f1be99f2975b1a9a52d27ace45f5928c7020",
+ 0
+ ],
+ [
+ "00000000000057d1aa7d139b6f28389f9f00682367e11cd6e179db6d78c21c5a",
+ 0
+ ],
+ [
+ "0000000000006e312b5a03b58c2ce0ea1f4e575290499e3e8628e55f57819810",
+ 0
+ ],
+ [
+ "0000000000009679b04fec87a5ef0cf0ceb8d799a6d01d5c87c2fd24c29468cd",
+ 0
+ ],
+ [
+ "00000000000026a1b24da68760401f5ae058d964a1830f8823c7958d6f0cb115",
+ 0
+ ],
+ [
+ "0000000000000ac34da5d627b47fc7f7b25599081a8b187ae52d70f05db9bcb6",
+ 0
+ ],
+ [
+ "000000000000025312bb3c989c7bff8448905d7e591c6d3cb16d3a560e632157",
+ 0
+ ],
+ [
+ "000000000000005e28077829e782ff8df6e9b907bb9df6493e4462d8285ea9e9",
+ 0
+ ],
+ [
+ "00000000000000498293c5ba0eea98e1bc9bc21178cb13033a11f8b49f5e774f",
+ 0
+ ],
+ [
+ "000000000000387bb87b8e7873d04bf3c2f70af050336a8536ad3f7735119f58",
+ 0
+ ],
+ [
+ "0000000041190a125657d94ea0ea26b2238b7a68f5eae7af62eaf902ac585923",
+ 0
+ ],
+ [
+ "000000000045beda231f53f748d8d0a0adcb0090603945f5664273e5f28e4c6e",
+ 0
+ ],
+ [
+ "00000000000c12330af00f371874b47f1f29a7d9bbb89f5d0a1e3a2dd53eaeb2",
+ 0
+ ],
+ [
+ "0000000001e172477961534ad79703de1bef9d7b11b27417c19dc4a8a1f3acf6",
+ 0
+ ],
+ [
+ "000000000022c7e8bfb93d5a5289cddd8d3083699997533f9f74cfe634f71f71",
+ 0
+ ],
+ [
+ "000000000024222a6945a270088ba37d36c48661d153c85676f87a879bdfe080",
+ 0
+ ],
+ [
+ "0000000000020caf59c7f3798fb533287568e2c5924b2cc5dfcb1da89786879d",
+ 0
+ ],
+ [
+ "000000000001a5f0697c842dd77697934e30bd8798c4a4aff1b7441dc6f23dca",
+ 0
+ ],
+ [
+ "0000000000000ba524c5e6b6bf1262f8ab7d9f74879bb41df26f1f5e5d13b4de",
+ 0
+ ],
+ [
+ "00000000000005f7c7e2f10a0f7d9d371a350ee6e6fb567b5ba6e7a43a9a2bf3",
+ 0
+ ],
+ [
+ "000000000000060cec749aeb779875b1be98f34ddcf541454cf4f1aff8e5d998",
+ 0
+ ],
+ [
+ "00000000000001f8ba23177cda6b436e7a2301e211fa35198854077254abeed9",
+ 0
+ ],
+ [
+ "0000000000000067d5b94b837fd148ab7eed397d1c0acd4599d7a8880c60e83c",
+ 0
+ ],
+ [
+ "000000001620cde7948f7134c978193490736b7ed19effdceeb142ac5c60c4f7",
+ 0
+ ],
+ [
+ "0000000000010b0daf2238423537f93efeabece0cba64ad3b5f8ac73c7382a99",
+ 0
+ ],
+ [
+ "000000000005b0676f677edb2b6710516e06bba963c09dafbfc2aac314126423",
+ 0
+ ],
+ [
+ "000000000007ccc73eca4f241bc1ff75302682c9fdb0939b6f706b9da00a52a9",
+ 0
+ ],
+ [
+ "00000000000a2a1678999a3493f1d603901e35518d90b3e739ce6daf127ebe26",
+ 0
+ ],
+ [
+ "000000000003b394dc98407ee9fd88e4f4e22507a788f915dcf72cc34c87cadb",
+ 0
+ ],
+ [
+ "00000000000351a1e8062b8fe34889239ba38ca52b3f7e44c7122fc1f0fe6e6e",
+ 0
+ ],
+ [
+ "000000000000df60569c57f26ca8796e636eed1357c8f0369d0821c0919ca7bc",
+ 0
+ ],
+ [
+ "00000000000087b5af3cfb531ded71bda8932cc80a453f22ead0bf93f50b08e0",
+ 0
+ ],
+ [
+ "00000000000057424fa933cffa9bd1116892a76ac912f2c8ee7313b2bdee3351",
+ 0
+ ],
+ [
+ "00000000000015c3e9e26653d1ef02aaa6d024489f90e83588f5f9ebf28d7b63",
+ 0
+ ],
+ [
+ "00000000000009016aa385782a3ec788fb22b41412855c89aeb73147743a6f19",
+ 0
+ ],
+ [
+ "00000000000001317fe059d7442ae8afc5aebccaf4d37ed31813a5caa3638c87",
+ 0
+ ],
+ [
+ "000000000000001d8f256752a735a1de33b92a928052f71b738d19ff366db867",
+ 0
+ ],
+ [
+ "000000000000001e9cf12031d4ab136ffd2fbc280607c4f996685d4aef460a11",
+ 0
+ ],
+ [
+ "00000000000000211e7a6e837358cae29446cb9c6dd5aa15c2284b5314f3bf46",
+ 0
+ ],
+ [
+ "0000000067af625d738843195c7dbf37d509f859a1875ae674e9a1dc8ad89e0a",
+ 0
+ ],
+ [
+ "00000000bba6b47cc6dcf36f0a6e33c7c60e9bb23ee1f72f62cb4e90d7f04332",
+ 0
+ ],
+ [
+ "0000000000041dde2cdeb55e44cc00c8d4c1f448c9220ed2c0153de7a2d55779",
+ 0
+ ],
+ [
+ "000000000000288c3e28aed36614437d861224fe8ccaf182e727b3e1fe1d633d",
+ 0
+ ],
+ [
+ "0000000000047b66b2cf5b617d9e7ece2ea2be7f886eca7e7f8f831a4c71a8e4",
+ 0
+ ],
+ [
+ "0000000000007a9fa498e65a666bd261e9a5ed00e6c3026e7916554841bea631",
+ 0
+ ],
+ [
+ "000000000006b245a6d974176dde00529110ea44bcbe8f78c567fea3e47a3d78",
+ 0
+ ],
+ [
+ "000000000002e1faeafaceacace03930ea6ea7d8d42ea4cbf141c2b578f3128b",
+ 0
+ ],
+ [
+ "00000000000167a7dbf143b59eaf118c2eeb5556061758b56981a4cbc6c7a08e",
+ 0
+ ],
+ [
+ "000000000000a352cbb22a1652956aa5d66610b696a19118b933048538192d2b",
+ 0
+ ],
+ [
+ "00000000000002c418d64c823e2ec8c77220717c0c7fe4e34b25afc4c0bad5e3",
+ 0
+ ],
+ [
+ "0000000000000d7373e7a596fa105a5d1b7dfddfbc274d45c864c724f0b9a2fc",
+ 0
+ ],
+ [
+ "00000000000002c3c89de3acbb14f5e38a6f5c3aed34d4c6c45b2222fc0fe3ab",
+ 0
+ ],
+ [
+ "000000000000001178732537c78ae21bbe8f9fed898f3b2c63692b9c93aba4e9",
+ 0
+ ],
+ [
+ "00000000dd9c07faaa65b8ce71e266699422567278b94487e9ebe4227d1ef2d9",
+ 0
+ ],
+ [
+ "00000000d226cc764f56ca5ec5a62562cdfc1bf3a4435350f0a27afdc5f94a79",
+ 0
+ ],
+ [
+ "0000000000009baebc276aeb84b5ccf3fd7aa95efc67c0f982bdfb084e40e9df",
+ 0
+ ],
+ [
+ "0000000000005d841db70c88b40085e6003fa220b0c91a810e04fb010e7f84e1",
+ 0
+ ],
+ [
+ "0000000000004a5013716cac6fe9e3d3c91b5688463feca69fc90045f00e2a17",
+ 0
+ ],
+ [
+ "000000000000cedd7a99d53af44ec7144316eec8aa0b04164f2618d090fa32fa",
+ 0
+ ],
+ [
+ "000000000000e311840eae32a946c81d8ed9a7fd5d1788444b5376a56def23df",
+ 0
+ ],
+ [
+ "000000000000c3babf9150364160f0bc91c0cafbf63849cf70a29574ed12751f",
+ 0
+ ],
+ [
+ "000000000000521dd5efaa7a298cecdd047c6ed85f981ff9185763beb9519c49",
+ 0
+ ],
+ [
+ "00000000000092695a2324a45f524b51ab700b458e5995790522f9d4002e374b",
+ 0
+ ],
+ [
+ "0000000000000925141aa9c81dd875e55a986bcec38a9ad9e627c81ee6437a88",
+ 0
+ ],
+ [
+ "0000000000000ae07f7535851cb685259a447d1ad5d3206fc4ee3693bb7421a3",
+ 0
+ ],
+ [
+ "00000000000000d41e23f89aad486957071e016d836382605770b65d7539d161",
+ 0
+ ],
+ [
+ "0000000000000016b6843174d892b13a0fa39cf807879b56b723075de7492118",
+ 0
+ ],
+ [
+ "000000000000001a7c215c98d09179f0558b18f6987150cfcf5e57afca65b98a",
+ 0
+ ],
+ [
+ "0000000000000027150d0a4ebf9001e210ebed81ab239535aca8fb5a489a1ead",
+ 0
+ ],
+ [
+ "00000000000000312d9a4fbd4bbde5e3f2266047e65f9a5e84474d62afea0514",
+ 0
+ ],
+ [
+ "00000000f82b6cc148557cb060b8cc3d697e38250630bc2e7188ad4500291b5e",
+ 0
+ ],
+ [
+ "0000000083ccf3997bad3cb32d0e46ff7875a0f454a3c48c2ff910d010801ad2",
+ 0
+ ],
+ [
+ "000000000000f40bd4d7c3d374a984d0c8a744c3816b713e8f43b9dcf75f7848",
+ 0
+ ],
+ [
+ "0000000000002c0374e865c02fed16da853c3086a28b0c212591469c95a71205",
+ 0
+ ],
+ [
+ "0000000000008301f16f29f38442d1b1f521650eba2382ea1e0055a291ca6422",
+ 0
+ ],
+ [
+ "000000000000a954b023180407c904341edba6375a982627b0724afc56f16505",
+ 0
+ ],
+ [
+ "000000000000c59c851bff2090533bc3009ae76cb1ee89e247dfaf11a5c77e7f",
+ 0
+ ],
+ [
+ "000000000000d779b30aab849a7f8a3af7f283bb95579d2d05714b6c3aeed955",
+ 0
+ ],
+ [
+ "00000000000016bca79f9de99fd3d0399f812f0fd5be4f84bd7ee442b846498f",
+ 0
+ ],
+ [
+ "000000000000dfdeb639143c64a17b99b2025a7d9bbb53e993ceb7c1656ceac1",
+ 0
+ ],
+ [
+ "00000000000023e3d31bc565be6041cad487f77bb23109aeaa804d72bb22d6df",
+ 0
+ ],
+ [
+ "00000000000007ebb67ae92e144382f52aebbc63a4604c8a07bfebfcf8a19546",
+ 0
+ ],
+ [
+ "0000000000000136c4a1582c01a5824f4fde4ddf91d653899b53994c4da9a3e1",
+ 0
+ ],
+ [
+ "000000000000001c197b662a51a6b9d5a4eb9521bc52c82f06aee07e8b58f47a",
+ 0
+ ],
+ [
+ "00000000ff1ae8e1ad7dc6a82747e125d99e099969d5fff2f193246529b225c9",
+ 0
+ ],
+ [
+ "0000000048af658629f65a2ef6052fc8cca3234f33d3fc329a0a2b4a73fadefc",
+ 0
+ ],
+ [
+ "0000000000008cef9b1eb41f402fa0f1f6a1ff6641ef3484d7decd9ceb7f1efe",
+ 0
+ ],
+ [
+ "0000000000003ff9199a773f976eda5420300f1f42f213d9d793ca002c17a5fe",
+ 0
+ ],
+ [
+ "0000000000001c6f16503cf6ce37c09f31eceac19e9a46eda67061bec5c6abac",
+ 0
+ ],
+ [
+ "0000000000002ee7040adca7f697117c59b8df1dc65519e3145d720b01add98e",
+ 0
+ ],
+ [
+ "0000000000009718224588a74633c646a7539d05ed503064e38f7734b146be9e",
+ 0
+ ],
+ [
+ "00000000000046fd759769b3296aa5636c3d113a309d633743e092b463072842",
+ 0
+ ],
+ [
+ "0000000000003594adc1bf018bbb36c059ac293164d83357817eb9b7b3ea320a",
+ 0
+ ],
+ [
+ "00000000000069f72a110745c74ead6d9f488906ee79cd2e6dfaa77f9371c300",
+ 0
+ ],
+ [
+ "0000000000001763cf5fffd8e1b0122a7d4bb0e1c2cb17bdf22fa25b70fa6e49",
+ 0
+ ],
+ [
+ "0000000000000c91abea6900d1ee2c168bc34abef1260776a164caecaaf283db",
+ 0
+ ],
+ [
+ "000000000000034624e683ff5df51b1a78fe027c67633c77258d3b1fca48a124",
+ 0
+ ],
+ [
+ "00000000000000b8afa5359b5cc460a77b047cbf8b1aaf640b8779c036c1cc77",
+ 0
+ ],
+ [
+ "000000000000001733362d084551627b7a71cf84b9365d4a8b1131d8e1f0fae9",
+ 0
+ ],
+ [
+ "000000000000001c3cd1aef22fb58f8917110976d342a0573aed0a466702adca",
+ 0
+ ],
+ [
+ "0000000000000015a4454fac29770dce9fc9786152a68807322ef00d74da0640",
+ 0
+ ],
+ [
+ "0000000000000005628d26b0af6507d1218a6665e8e6867ab37b84a69ba15cd8",
+ 0
+ ],
+ [
+ "000000000000001551c40d57827ecb548a09f2512ab66a3d3dd86f00f8083ae1",
+ 0
+ ],
+ [
+ "0000000000000014b20ef449f75f3c015bcef0e19d302e440f9ecb4c183300dc",
+ 0
+ ],
+ [
+ "000000000000000e814b868e01fe7a4a78d966e4ef73f5293c633005ef5718dc",
+ 0
+ ],
+ [
+ "000000000000001e4d4ba22dad9356f9753b1065765799c080807a075964f8a8",
+ 0
+ ],
+ [
+ "000000000000000c2726580d5aaf194818abcb0dc9275266fa6604b792dcc41c",
+ 0
+ ],
+ [
+ "000000000000002660dbfdb21d80939f5341395eacbd2edd67a61abd58044345",
+ 0
+ ],
+ [
+ "0000000000000027fb8a498f621a696f6ff1d9b45839940a0a45700f2e211bc5",
+ 0
+ ],
+ [
+ "000000000000001d3fc884a35029348110a0af44cde5f299c89a89b2f5e3c1e3",
+ 0
+ ],
+ [
+ "000000000000000edcb8421d37f2d46963dcd2ab31a0359574b49918627c4772",
+ 0
+ ],
+ [
+ "00000000000000148683ea86525485d22e85bda982e8682f5010865ca3ff3da2",
+ 0
+ ],
+ [
+ "000000000000000329cf100ca7c05279430275cedc3f4573dce6ac1d418b7734",
+ 0
+ ],
+ [
+ "00000000000000054a4bd09b4cf258f0bdd86feb97fdc38d66753f3e04a70524",
+ 0
+ ],
+ [
+ "000000000000000faf34df569486fe78f0b17236e2e855a507e5d36759b95751",
+ 0
+ ],
+ [
+ "0000000000000008c72a42e89efffa7da953b94c2217f50328d23a098933d6cc",
+ 0
+ ],
+ [
+ "0000000000000db7c34e4a5e1e2445b7f4e07231e5736251103a1d1dc5b5943b",
+ 0
+ ],
+ [
+ "000000007795c87d221390511e079257789f1563bcc772571e4481ca3b448832",
+ 0
+ ],
+ [
+ "0000000004e5b3b03b0223706a7ce40d576d64026784c2ff8614e6562f4d018c",
+ 0
+ ],
+ [
+ "00000000004a0ea723460162104583b750bea77bac8c967d801c11f5058ccc04",
+ 0
+ ],
+ [
+ "0000000000000ce34ac861ba5d727e47e9e19cf0db6df7926ad1871e9b94066e",
+ 0
+ ],
+ [
+ "0000000000001ac9fe6e55ac14cb60f3bc8e2bbd3e35b86a39a64aaa8b71ca54",
+ 0
+ ],
+ [
+ "0000000000001673268e6db4c8f163bfe25914d4bd50673da5715d8313b7d191",
+ 0
+ ],
+ [
+ "000000000000068296732b209e33279ba86496a74bff278c6b66e8004c8d29c5",
+ 0
+ ],
+ [
+ "00000000000091c08da0ddc2340050cac7b30bc5c1c83dd3e7dedf5b08dc3078",
+ 0
+ ],
+ [
+ "000000000000770a2e7934ceffff83c29b049fa202759b4faf4598fc0fa67ea3",
+ 0
+ ],
+ [
+ "0000000000001b5d77a10cd4842db233e04c268e57e3b2669aea7701da12adf1",
+ 0
+ ],
+ [
+ "0000000000000182a40214f1c538f5a44b696d630664456aeaab29264a6f184b",
+ 0
+ ],
+ [
+ "00000000000001245d616ac4b49515cfed74ba0ff4b7e8934bf18848075937b9",
+ 0
+ ],
+ [
+ "00000000000000321a47c18d57d5d8d058bfecb43cc19af593359a66851fc605",
+ 0
+ ],
+ [
+ "00000000000000231e08d8080db64b437e37f17295c82b8561e528829970e9b2",
+ 0
+ ],
+ [
+ "000000000000000fb903e2942029e3a65aa8d26efb48aa8537aafb3314ff6d60",
+ 0
+ ],
+ [
+ "000000000000000109729ddaea18bfb7a1f3dda86e11332946cc34d27a988420",
+ 0
+ ],
+ [
+ "00000000000019477d9b273423ac45be472ac63d88616e5169efe9e3bdb03fb9",
+ 0
+ ],
+ [
+ "00000000ea0c1249ac03ea5667ca8ff4f327468d873b6a9bb78206e9c3b8cd63",
+ 0
+ ],
+ [
+ "000000000006321457285a8cca551c77825721f502b83ca06972d697c9e5ce1f",
+ 0
+ ],
+ [
+ "00000000000214094cbb0ffedab25fb82fcb3db22ee6031e6b952b73fbacfedb",
+ 0
+ ],
+ [
+ "00000000000541d2b25e067f1707b06d91f19351b84d41aae164ad41facec281",
+ 0
+ ],
+ [
+ "00000000000164bec334a2c1f79e4af9dce78d838d573b90fc5acd16f544b3da",
+ 0
+ ],
+ [
+ "0000000000030c337a7f2bde97cbf6a7c71a0b4d24e1e4600e46eac009069221",
+ 0
+ ],
+ [
+ "00000000000555a5e32651e24a8b83f4aadfd689ba44d49e63329bd2ed484078",
+ 0
+ ],
+ [
+ "00000000000207e99a06cfc158f5d7d8cb27234cab15ab00fec24fd8b8956aa4",
+ 0
+ ],
+ [
+ "000000000000c819f87342c54f0d4970443264a296a88bf38848ef1bcefa05af",
+ 0
+ ],
+ [
+ "0000000000001838f689e4d297db56c3d50753378cb2458dbb5f4392ea90c585",
+ 0
+ ],
+ [
+ "0000000000000996e7334726403dcb4d2818096f645f6782222e0c9fa8ab366d",
+ 0
+ ],
+ [
+ "00000000000002502c65e6a2ea56681d90d9cc6e774929a6ac17c1adcb0c6aeb",
+ 0
+ ],
+ [
+ "00000000000000f2b4ec952983f6e68cb3a46d34738c38a958de3ced1da54e42",
+ 0
+ ],
+ [
+ "00000000ab4d311932bee7b754ad97e081cc24b0225d3089dae7440fc084c623",
+ 0
+ ],
+ [
+ "000000001104264440198251f8c9046dca4a3ffbacbc48be03baf996c3a48094",
+ 0
+ ],
+ [
+ "0000000000023cb28ed8ef3637a80e73ec92ea7210088053002be02818fb9f98",
+ 0
+ ],
+ [
+ "0000000000002bb738283a319f1f1b8bbe365b7f6f295982c29836d193808f6f",
+ 0
+ ],
+ [
+ "0000000000001a15e8f691a8bc14f3d694abef473c5818017ed391615b45fa39",
+ 0
+ ],
+ [
+ "0000000000733fe4dca7d1dae0a748bbd9b3b99e687b97281b806a199e35ca54",
+ 0
+ ],
+ [
+ "00000000002636ed79a1d367fefb464aa532b26dbbe8687ffa5d5f26eeee06dd",
+ 0
+ ],
+ [
+ "000000000006f45c16402f05d9075db49d3571cf5273cf4cbeaa2aa295f7c833",
+ 0
+ ],
+ [
+ "000000000000dcaf9949232b5cf92cc2976ee59521f93ec2197a9c762c6a3c54",
+ 0
+ ],
+ [
+ "0000000000006c7706b78284427681abcb62c432adc364975590f3b33d94b773",
+ 0
+ ],
+ [
+ "0000000000000efdfdbe08218cf961d25cc1956d464a9f067f25545301b79222",
+ 0
+ ],
+ [
+ "00000000000009ca0d54316bdadcbaa48c53fab88b9fb0556472ed9e91751602",
+ 0
+ ],
+ [
+ "00000000000002395eb7f05c543dbbc241cd4b5d64b3c948f64d8ac2083b197c",
+ 0
+ ],
+ [
+ "00000000000000e9883c7e7284799cdb9ff4e4208a313050a182172c950f7e73",
+ 0
+ ],
+ [
+ "0000000000000010b95f7caba61a90def8a4b527ce0574092718f478af780c10",
+ 0
+ ],
+ [
+ "000000000000001fa1eeb7f86389fada9fa3e05a8e497c23a08ed6a1dda63a8b",
+ 0
+ ],
+ [
+ "000000000000001086a413f0cebe3ab3aad747f6b34a5038856592675f4efef9",
+ 0
+ ],
+ [
+ "0000000000000028a8f135e691760cbe5c4b7a0d88a4318b8fb353aca220025e",
+ 0
+ ],
+ [
+ "000000000000002460d6c79c6e2cde645eec7b64d4fd48a1c71d756cc9c3dcb1",
+ 0
+ ],
+ [
+ "00000000000cae97fda2191050892ea192e7c15881773b17e3bc1331822fe4bc",
+ 0
+ ],
+ [
+ "00000000002d5f11ec72e37b756a94058c299fc647d1603efd6067e20c24d306",
+ 0
+ ],
+ [
+ "0000000000035d32b34aa575bbf8420ca6caa6646840539c47d842c8b6700771",
+ 0
+ ],
+ [
+ "00000000000038ea640ea920adb8ac40cccdc56ada27cb52e3ae06ba0580572d",
+ 0
+ ],
+ [
+ "000000000063dfc0373ccbd4d400980fe603c0af468d21d3ff8eb568b480ea65",
+ 0
+ ],
+ [
+ "000000000000a46891a576d73cad07d20f3ad9308657bc676b12a4f066915ce2",
+ 0
+ ],
+ [
+ "00000000000d58b8cf55bd312e564ec6d2959162b546dba8849b0e4e0dae37a2",
+ 0
+ ],
+ [
+ "000000000001e72b87fa955829ec8dc21878f11db8181c7475e2d03c79bd0e13",
+ 0
+ ],
+ [
+ "00000000000032848796aec72da3f9dc0ed83ccb99023e9afafd7f3d9bdf7103",
+ 0
+ ],
+ [
+ "0000000000002e93d090b9aa138cd12a5362e0cd4232a3b96561fa7bf280a103",
+ 0
+ ],
+ [
+ "0000000000000dbc7b9754521c68f2553265437d589fb6b2615dfe4d960ad690",
+ 0
+ ],
+ [
+ "0000000000000fe4edb18d30fbc59db0c34a39b33c878108825a7d8df4d99b2f",
+ 0
+ ],
+ [
+ "000000000000003fe4956957ad4d5c19c79613f9840ab51bb841da535b449861",
+ 0
+ ],
+ [
+ "00000000000000fd3eef340a6953ffc756eab83dbe091bda721387f7249835c6",
+ 0
+ ],
+ [
+ "0000000000000062e7ccfc5414355a0a1b5151b496d1b77abe7606920c0f5251",
+ 0
+ ],
+ [
+ "000000000000001f6d8dc4976552a596eff2eb0df15b0d9ee61a55091a2050c2",
+ 0
+ ],
+ [
+ "0000000000000018a7ce07d0ac46c3eebd72bd2db0db627675e146fcf9278e4e",
+ 0
+ ],
+ [
+ "000000000000000be86cb1664031f8666023b52d247063327613b00619f66514",
+ 0
+ ],
+ [
+ "000000000000000c1589a255f9fe686ee448e8bf60424529c1f842b71bb317c6",
+ 0
+ ],
+ [
+ "000000000000000a2d14a86314974abad5934ed38f63276fec039d636f33d652",
+ 0
+ ],
+ [
+ "000000000000001737d639951d593f9d269bafda7d5fe5a667d41f8d8d2c9cfe",
+ 0
+ ],
+ [
+ "00000000000000033f81ecaba0452707d61b7e76d1b18809b20db18de30f3b00",
+ 0
+ ],
+ [
+ "000000007f31278326e2cf458be3fbc904d4f98f3b348c8f2f3042d590fe2ddd",
+ 0
+ ],
+ [
+ "0000000000018ca40c2e36e0d484b57d41714c2bf5bf69ab06eb214c252d63f3",
+ 0
+ ],
+ [
+ "0000000000026de8f53cc5ecf634c484105891ef12e8abfc3e83d750c07d89b5",
+ 0
+ ],
+ [
+ "00000000000d340049196286b501419b72c66bc6a45ad177690e0ff641c6418b",
+ 0
+ ],
+ [
+ "00000000000e70c72347a1043a7e1fd35483242689618a5576fdac0845dc96a0",
+ 0
+ ],
+ [
+ "000000000005aba9cf40fb0f07b3226bddeab8b997de4e827ff9d9f7198f7ffd",
+ 0
+ ],
+ [
+ "00000000000c1c2b6e4b064438ecedc3028edb111fa571062d62f84912f407c0",
+ 0
+ ],
+ [
+ "0000000000016e1ed29108b0224b172a3247f61b7589526566cc62ac69ce9b5e",
+ 0
+ ],
+ [
+ "000000000000f0e2e3a76f57f843ef8823216e1789c9cfb97f17f87b719a22fd",
+ 0
+ ],
+ [
+ "00000000000022998f49c0c1a4519125272270b9d59857f0d302c76017171c84",
+ 0
+ ],
+ [
+ "00000000000008588b178655052953dc2eccf8c0a0648f15c1d5ceeedda91372",
+ 0
+ ],
+ [
+ "000000002cc217b3217e3016d6e6eba2584619caacfe944342ae905fe24c87ef",
+ 0
+ ],
+ [
+ "00000000000030120f45e78e7852729d6925cdb3c5dbc87eb8f167674e722bf3",
+ 0
+ ],
+ [
+ "000000000000355aff4c8c416f3ec39b77071a6614cf604c213f3e52a405a221",
+ 0
+ ],
+ [
+ "000000001d5087abd326f27fcff310c58999d8881e5bf0cf30c826177680508e",
+ 0
+ ],
+ [
+ "0000000000002393b75ee9ce1a0433cbefb3fad205406fab65bbc0a61a757149",
+ 0
+ ],
+ [
+ "000000000000c57b6c1be1998660d54eb13f10ba3371cb735859dbb7c4396799",
+ 0
+ ],
+ [
+ "000000000000bbe163a8a5f68e9ca2c99bf9af0a9fbecad4170384a0bf907b26",
+ 0
+ ],
+ [
+ "0000000000005d2078380dd0389664a0de79dd7c6a8c1b94e0522b0bec15f74d",
+ 0
+ ],
+ [
+ "00000000000029b25af79b8d27068c32865d2a41817fd1a34586ee280c7455ad",
+ 0
+ ],
+ [
+ "0000000000003af3be1fa30b8c225a0b9061aa07e3389cb44161d11b15ee59b5",
+ 0
+ ],
+ [
+ "00000000000006450af5e500b7a650da7a2043c7f3936e3aa93cd08c22457341",
+ 0
+ ],
+ [
+ "000000000000a012ef365be0880e3d7ba88a9a8378429733d608c6cb6d7fe59d",
+ 0
+ ],
+ [
+ "00000000b3a9e0680e5ecae9a639b6ccb19456c59eb66ea1b53495f8cb579874",
+ 0
+ ],
+ [
+ "00000000015eb6c7abe02adb992d359926f511d9830c6a18b8cfb8b3bec85cc3",
+ 0
+ ],
+ [
+ "000000007699589244ef2cc09903aba91032e7e86d4e773ef639ff0150db2fe7",
+ 0
+ ],
+ [
+ "000000000000fe9483a6e60c2589355742d93b30406a2d273e10ec58558cf34c",
+ 0
+ ],
+ [
+ "000000000000b02a657620c34404936792268e4453882f203ae7add74573083b",
+ 0
+ ],
+ [
+ "0000000000002c7630b21c1ec243de251dcee944ef0ec3cd9d559c34f1fa7d4f",
+ 0
+ ],
+ [
+ "0000000000018c93ec3fde56ceea4839e6d08856aca055508c562d96e16dbd71",
+ 0
+ ],
+ [
+ "00000000000015db7b745e849b5a05399ef66a96d31809fcd79556ed7479965d",
+ 0
+ ],
+ [
+ "00000000776cbc7a008bdfa21270d6f18c58d8a794dbf7355cc71ea8a0c8c063",
+ 0
+ ],
+ [
+ "0000000000002ffc1ec2f0f2a757d589e452794b76e50366a452ad3d318f15c2",
+ 0
+ ],
+ [
+ "0000000000002d727d581f7f0a93b74f5585e62dcc6fdc9cc3b8b19eff10f700",
+ 0
+ ],
+ [
+ "00000000000010bb6ada118d713af3ca721db8622bf222477c3d208f8b3061c9",
+ 0
+ ],
+ [
+ "000000000000e03cf9e4498fba163d2bcd2646991441169481b940d291fd075f",
+ 0
+ ],
+ [
+ "00000000000026e1da1de58906d3d12221a79250f10abb77623a230c8a62fdea",
+ 0
+ ],
+ [
+ "0000000000002475dbf8647609a128ac7211c4ca7b728a989f1b5481e626a328",
+ 0
+ ],
+ [
+ "0000000000001f2e545dd92ec4a9410e3d6a5bf11a6bccf97f049310538ded2f",
+ 0
+ ],
+ [
+ "000000000000ac4ecb8bc7ff25f1aa9633a9cfa28d0bef19870c51e0a9431f30",
+ 0
+ ],
+ [
+ "0000000000003c493ecc2121c5c3bdca88a141444b2631d6a9b720a504bf455a",
+ 0
+ ],
+ [
+ "00000000ad4ae3258584eca0ac04c31af2ea25d1d2b811279994c017136d160c",
+ 0
+ ],
+ [
+ "0000000000edeb17c5a1443fbd6f9f9ff20a1691bde5fa42ced3fbc6413a5e01",
+ 0
+ ],
+ [
+ "000000000b61f092a5c1660d0f8f6fb3294c9b065c4ef2943a2955a2d03c3708",
+ 0
+ ],
+ [
+ "0000000000000ece5c82d89b13143a1a28db9b12bac561928c0a1d709faeb479",
+ 0
+ ],
+ [
+ "000000000000000929103fb8a1aa3f8215c61e990f02cc6085627a6cbe197e00",
+ 0
+ ],
+ [
+ "00000000000002c9b7ef56519aeb047fa614198081393923ce8f78db40e7ff43",
+ 0
+ ],
+ [
+ "00000000534f7159b8c190680b93775e91460d5203160789615a92e821e3beca",
+ 0
+ ],
+ [
+ "0000000000003d1a6c7cf11ac53c59a12fe127eb9c2058cc6a2e5229136c8c3a",
+ 0
+ ],
+ [
+ "0000000000001749960590ed08ebd150823d21b32020ad8b219adce32e7344c8",
+ 0
+ ],
+ [
+ "0000000000003384f9d5b10cb0321baf1a99b5f37458155ab794649b12eec2a4",
+ 0
+ ],
+ [
+ "0000000000000bd9def7511faf3303d889daae01de92f5716a6409c44f9ccdba",
+ 0
+ ],
+ [
+ "00000000000014b324e5afdc4b5899942a9bc776d9cb00ec2af5b795c3a74fda",
+ 0
+ ],
+ [
+ "000000000000415e1f654f779786094eab0dc703010b3f686baf7defb83343d0",
+ 0
+ ],
+ [
+ "0000000000001c9b4a7c7e351e10f47852dc9d5c6b7c7c4518591fb6ddaab3a0",
+ 0
+ ],
+ [
+ "00000000000052b7b9fea051b3fe3244d1b86516aaea33f2d5a55e977fe9c026",
+ 0
+ ],
+ [
+ "00000000293536c90c630e207de478560eaaac89ae8afc33333aa7963dd8b7b2",
+ 0
+ ],
+ [
+ "000000001a45d126188b331059d04a98df3588887adf6a2b520225a2a2b03567",
+ 0
+ ],
+ [
+ "000000000010a96f7c94770392a3fb38777d9d75ff755b5043919475ac396121",
+ 0
+ ],
+ [
+ "0000000000007e19b090529e49795e4c115c7f00d327da0568fec93d542ec878",
+ 0
+ ],
+ [
+ "00000000000016a2c2a305974b890779ed07afecdc45e9de20397f52a88efe36",
+ 0
+ ],
+ [
+ "00000000001588ca17c8d2f9051d75f085daaa519b85c7e25d14b5871c4cf25a",
+ 0
+ ],
+ [
+ "0000000000000e31ba2c3036fb984c5faf3e2860e41799aedd75b469287e8f34",
+ 0
+ ],
+ [
+ "0000000000003236b8dea540661d16c62aed83879951331d7ce97290a761006a",
+ 0
+ ],
+ [
+ "0000000000001f30b54b080cec64d819fb2270932883cc048b7e614ff3405b1f",
+ 0
+ ],
+ [
+ "0000000000002d5f3ebb70abd3119117802b2878af6fcefb58f43794a152b6fe",
+ 0
+ ],
+ [
+ "00000000000001ba1b76793d0ef69a1cf35b8af6421daba0224db06bacafbc1e",
+ 0
+ ],
+ [
+ "0000000071289acecc04f86e0d71e4dd9af80c1d62d7d3d8d2a4730b458c1694",
+ 0
+ ],
+ [
+ "000000000077d6b5ce95ac4bb2607afebae36fae00f4d9f668275a1d42025a1d",
+ 0
+ ],
+ [
+ "000000000001f970e0880b7f3f9456e5b9211d2e9a407a664a0cf5d79fb0d07e",
+ 0
+ ],
+ [
+ "0000000000000c5cda8f16ddba06e9307903d6e75b6f0fede174f9d1214e85af",
+ 0
+ ],
+ [
+ "000000000000339de4dd4e26cfeb620ee6958b460f30d7bfb31f027736a51fbf",
+ 0
+ ],
+ [
+ "00000000000039e58cfdcc21226f2499dc5f88a8c28e39c20687d2ad5d4f41ef",
+ 0
+ ],
+ [
+ "0000000000002db5a8e0bdced8ffd6e4803bc51a425436e2c05f90a1c69cac03",
+ 0
+ ],
+ [
+ "000000000000c76e1a3b84465e3ef6de278a563e602e2e9040c18a19053bec8c",
+ 0
+ ],
+ [
+ "0000000000002cd1ffe2aec0e5d9d6147b21e89f638b3960daac6c5ff98f6083",
+ 0
+ ],
+ [
+ "00000000000016305181caa8d88541b5495065c6231b67cc3df5430b4d0b8d46",
+ 0
+ ],
+ [
+ "0000000000000161c619a838b35752b87b780f702d0e314a6acf517af3e36232",
+ 0
+ ],
+ [
+ "0000000023588058c8895766d31ad2c44ccee1150dcfdb6218add8711b205544",
+ 0
+ ],
+ [
+ "00000000004acb3364caf13a892af294d3ac17340c3713f38e866647e7d2c2d9",
+ 0
+ ],
+ [
+ "0000000000c238dddd6860a7b597f0974e5f870b39f79f00035734e287d8891c",
+ 0
+ ],
+ [
+ "0000000003f232ff7c873bd2668c438e93974a157e8e44dd6057a23c406a04bb",
+ 0
+ ],
+ [
+ "0000000000003684f0ccaceb330a90045b3f5b6e790c55fd9214adb694bc6151",
+ 0
+ ],
+ [
+ "0000000000000ca6a9286942ffa849e82cf75b15f785bcc0be82009625341703",
+ 0
+ ],
+ [
+ "0000000000002a7dfd1764890b04221aae58e78638dad039a952be3aa2b270f4",
+ 0
+ ],
+ [
+ "0000000000006b39c0cef1f968bfa7373478222c7804c47671e92b81df352e0f",
+ 0
+ ],
+ [
+ "0000000000007f70368bff3515e745dbd8da7bc9bf5846bbec997c76c4e9f598",
+ 0
+ ],
+ [
+ "00000000000016ba9493ea10dfdc68483cdd3a1246bfe5a42c3042fe46e31452",
+ 0
+ ],
+ [
+ "000000003315cf62b16bd31e5ffff48a77c4332466e75653de573596dc9e6811",
+ 0
+ ],
+ [
+ "00000000000112b139086612b6ab6d98836eff016c28da0b68cf51a63d1c5ab8",
+ 0
+ ],
+ [
+ "000000000000740c7bd5664ec75f595399815676a1cb31da175f55b216c0489c",
+ 0
+ ],
+ [
+ "00000000000040e34183c66e6d8803b523f132aaa0c53eb0d27581f1cd202fdc",
+ 0
+ ],
+ [
+ "0000000000212b96599a158f45a8f8ace26be06d8c77ca7b5dc4f4cd5d2479ee",
+ 0
+ ],
+ [
+ "00000000000047ec63694024c7f4a742b506dcdf87dd6d99ef5166fe070ffb20",
+ 0
+ ],
+ [
+ "000000000008bdd2ec2fc909564ad5770d9ab7f88c95c7a2b0efd6452b7ffd12",
+ 0
+ ],
+ [
+ "0000000079fcb43bb7271e55d15c27b718cefec8d1d22a1cead4061896d25f12",
+ 0
+ ],
+ [
+ "000000000000e0d307898c7a1ef362c31bb7c4436075444c6f8cb235f02239a5",
+ 0
+ ],
+ [
+ "0000000000001a9256df4ffa52b4e184e713b5a0f5315b0d4ce1a3e77c916464",
+ 0
+ ],
+ [
+ "0000000000006b3b9c9117ab64f197146a313f0874535fa4c9a3de4fc37b0461",
+ 0
+ ],
+ [
+ "00000000e14400a3e77585e044d3a526fad991db8af00392286b7fc143e69b12",
+ 0
+ ],
+ [
+ "00000000000e8b4bd2bdd5ad3c06cb2fa234c271ec25ca87ce3941dcbc49afa8",
+ 0
+ ],
+ [
+ "00000000017ed9122f0371f08682c5fe6aa8418b265efbab0993b1ed00a89f45",
+ 0
+ ],
+ [
+ "00000000006f039a8997a1b95777b781d9650bb184cd80eabab31aac94a278f1",
+ 0
+ ],
+ [
+ "000000000000e4b80abe3cd2441ea4eb0a5cdb6b89c0e8ff3f369cfcedd0cb68",
+ 0
+ ],
+ [
+ "00000000000026117fc0fe66f1a60bd42bc2d091d13ffb349dc60277da717fb5",
+ 0
+ ],
+ [
+ "000000000000085133f8602b7408ee4d7b9e777174d520da980bac6a2bc746ad",
+ 0
+ ],
+ [
+ "000000000000b0429a45f601bc84f00778ce1e7f5c3636a7c215ad6721c7927c",
+ 0
+ ],
+ [
+ "0000000000001cf9d3f7f37d9c3999b05d52e6ba5434931741aeacf262bcb9c1",
+ 0
+ ],
+ [
+ "00000000000007f291ae30aeb2f85877116865e06e3f10658c3b3d8de7ba00fe",
+ 0
+ ],
+ [
+ "0000000000000c460fafe518178f959512c3966bbf6569869e51ca81d69533ba",
+ 0
+ ],
+ [
+ "00000000000001ded7aca43353f5258fabb96db1b785fab0daef4c427e9fe170",
+ 0
+ ],
+ [
+ "0000000000000002bb51deb7934ee1abaddb6607b267a9f7947f279c2848f064",
+ 0
+ ],
+ [
+ "000000000000004b2fac327d5bdd9b7e12512547ec70fd7c3bfa0e73c8bc17da",
+ 0
+ ],
+ [
+ "0000000000000002b5ae6d256f769f4c445f595277e679ff34b78dee498566f9",
+ 0
+ ],
+ [
+ "000000000000000760ab2425e4fbdbdb5fd588b5e2bbc15d57e1431e90699fdc",
+ 0
+ ],
+ [
+ "000000000000000047c410d3799304f0e61c423993f6442291ae77cd06bdb113",
+ 0
+ ],
+ [
+ "0000000038cd3783bb8bea435b42e3870544e610dcd642da655faf8c4011d455",
+ 0
+ ],
+ [
+ "000000000000048d4df1b36218595e7626bced446edf8efb8394b68a3fbd1e6e",
+ 0
+ ],
+ [
+ "0000000000003fb4daabfe5cd01b7da1808668ab18c68353d1ee0546453682fc",
+ 0
+ ],
+ [
+ "000000000000277e15671d375ce9a4499b66f1f8b33c4f6afd74b5ea20158614",
+ 0
+ ],
+ [
+ "0000000000002ab11ec0ab446ac363dfc93a9bf09a28d83a85b9bc163d5fa5fd",
+ 0
+ ],
+ [
+ "00000000000002d74bb89f8320bd80b07f7b8bc4b77987039e6fce894d63c966",
+ 0
+ ],
+ [
+ "0000000000000f1c86e38fec5fb04c19440c066205f89f0ba151cfad2a41cbfb",
+ 0
+ ],
+ [
+ "0000000000000168baff0b9e9c678a5153004f7f241f52ef1e49d88c1bad2ef6",
+ 0
+ ],
+ [
+ "0000000000001b3bd58011210021d92656719030cfde3c033bedb3c1e6a81eef",
+ 0
+ ],
+ [
+ "00000000afe6a4017bfcd2c3c9a21efe4c41951888a9c97c5c3d3bba9f30ea68",
+ 0
+ ],
+ [
+ "0000000000000836cb2bd14463313660efed9d0e23339fcaa97c54295e9716fd",
+ 0
+ ],
+ [
+ "000000000003e336698dce2d615f693645b4d04f8e5f98d02d94fca99bd5a60d",
+ 0
+ ],
+ [
+ "00000000035b7a00de4f346dfda49c16db195867f3c395d5d83785acf51908a5",
+ 0
+ ],
+ [
+ "00000000000018ba5cb649b9e7a5458467e7275e4e3284dd1d2dee9f0a64a2f2",
+ 0
+ ],
+ [
+ "000000000000239369e70e689f53bd3ab435c3eb6e5a26cccd52dcfdab18b255",
+ 0
+ ],
+ [
+ "00000000000030694285ec9b1b6349dd97757b16355fe07de048f969119575d9",
+ 0
+ ],
+ [
+ "0000000000000657b62914a9c16779754d11d1c444b3bd0965a571f7afdeab21",
+ 0
+ ],
+ [
+ "0000000000000e1429ee6f6266eaa4a21b71335503eb73bdfb4f736a1d9fcd3c",
+ 0
+ ],
+ [
+ "00000000000021d4eb583b65086025e1147e7783b71b61e94b0b1d01dac32479",
+ 0
+ ],
+ [
+ "0000000000000afd677ed22ae10ce1ddc3c9f3e73e1c1d1398cdfb581cd43cb3",
+ 0
+ ],
+ [
+ "00000000000003fdd00758300566d8f27cc9461e70c64f3b1ed8e20c1c2f26ff",
+ 0
+ ],
+ [
+ "000000000000003057478e768b1d129fe5f1acb1a99818df164966c68cf22471",
+ 0
+ ],
+ [
+ "000000000000000072bdeb5162f6c77003778bf3c77520028687e0137a58d8dc",
+ 0
+ ],
+ [
+ "00000000000000070be95e5fb581cd857fee7617219ce4047d31aea097cf9fb7",
+ 0
+ ],
+ [
+ "000000000000000737a7a678467547316706f3ceaf9d8d0a56e50d63bb834a74",
+ 0
+ ],
+ [
+ "000000000000000591b541ed7088c4ce52fd10a0b99a4b5db377a3c1ab198756",
+ 0
+ ],
+ [
+ "00000000193b4863d9b143d45b6db44ab8706e1eb4cf76e960b6390d8654f317",
+ 0
+ ],
+ [
+ "000000000000d99c10079c94e38f6153dabe29d5b0315968016e70049f0ba72e",
+ 0
+ ],
+ [
+ "00000000001603022822335be28b6e82ae1761ff293913016f577fc6db2e2d46",
+ 0
+ ],
+ [
+ "0000000000002ad9d4f5d7246b599fc9acdffd10b30eeb1adea05d38ddb9986a",
+ 0
+ ],
+ [
+ "000000000000050756381c62b7009c293513d5141798b5061530fd74aac98bb1",
+ 0
+ ],
+ [
+ "0000000000000dfe8be3b1e0dd83a4babb473a79242f40264f21936baac8facb",
+ 0
+ ],
+ [
+ "000000000000f77717ca2bb34b74dacd3ee6cda1e660f18498a386da0a884bbb",
+ 0
+ ],
+ [
+ "00000000000a110d1be3a230af9e48d99457e00a17ace1b3c5d425e95611bf05",
+ 0
+ ],
+ [
+ "000000000003d50b2c17cbf2d5df1556f0849a33096d72bdb3e64c426202961e",
+ 0
+ ],
+ [
+ "00000000000067dc944a93268621c566e31b879bdab5c1966510d73254aa48c8",
+ 0
+ ],
+ [
+ "00000000000035fabad3658e0d84928ec43f7b66b9efb872d907e1af0a86dde3",
+ 0
+ ],
+ [
+ "0000000000000a1a7a49cee85178df151625b1f9fe1857ad2bf0d47aabe0c2b5",
+ 0
+ ],
+ [
+ "000000000000021c7bed7852ba91de97ffea03f5f3ae1d6b7dfdfd002ff6b01f",
+ 0
+ ],
+ [
+ "0000000000000031bdc46f3de83a4d5a9b164348aa33393ff74fb67c22f73dd9",
+ 0
+ ],
+ [
+ "00000000000000388320f1a185ea53ee5bafeb4bb7b23ae05355db3ec3b9f3d5",
+ 0
+ ],
+ [
+ "00000000ea0ddfa9fcf128f566b08d4341b608b85d2d0cdd05156a6b5b49b663",
+ 0
+ ],
+ [
+ "0000000000016eb90eda23984b9615fa7f989aef9b339d8521624793c32aec32",
+ 0
+ ],
+ [
+ "00000000249b02d570ff2e7a1886eab612d591c58c15caffac878f9d7e69a2a2",
+ 0
+ ],
+ [
+ "0000000000001601a74f55eb45449c945c069cd228bbd28b662a4cf929a07b26",
+ 0
+ ],
+ [
+ "0000000000001d54ce071b6e1adc6489ead4869d27cf4467e09865417d7ede04",
+ 0
+ ],
+ [
+ "00000000000006b263d940ab956c185c9e8a39806c5d3ac9f04dda3b8cade6a6",
+ 0
+ ],
+ [
+ "00000000000018eb028b23c464eda0f6addbb9c36adc134fd7300f6c3306df95",
+ 0
+ ],
+ [
+ "00000000000008e0b67636b199f21f04fd3d413e9678c39d27e30536a1b249c0",
+ 0
+ ],
+ [
+ "00000000000008df86828e07c01137240d45cf9ed5fff249d071d45786d1f4b3",
+ 0
+ ],
+ [
+ "0000000000001bba9b529e6aa2cf8cdc83264c806e079f5127831fcf44166bb8",
+ 0
+ ],
+ [
+ "00000000000025e87dc84bc04654c2b30a87d862c0a3420ecd3084670b647d7e",
+ 0
+ ],
+ [
+ "00000000e916d01f1d6d4f170d88cd65a778a940ce11c55996a397f21030dfcf",
+ 0
+ ],
+ [
+ "000000001ea4d2da640a2a166e8a51a3cc5557fca1c2de40acc810ed9f9d29ad",
+ 0
+ ],
+ [
+ "00000000000018a01a5f01b8b543f5c211f55d532a03c9a04384ae4257bf61e6",
+ 0
+ ],
+ [
+ "0000000000000690cedd07d7bdd3ca2bd2029abe58919600b4fabe99fe4e412c",
+ 0
+ ],
+ [
+ "0000000000002ee49f8029332f30be5783dc2edca08ad279f114bf6c231d1dbf",
+ 0
+ ],
+ [
+ "0000000000003aca3ef92013e9c559c124b27d9df45bc5b9c620f30987cd6d71",
+ 0
+ ],
+ [
+ "0000000000003b73bb514fba5f8f063fba66c91534f1f6ec4ad49ea89bcaeb4a",
+ 0
+ ],
+ [
+ "0000000000001e461eef655c3ee4f013a079ff05e750578cf3195399f274688e",
+ 0
+ ],
+ [
+ "0000000000001d16126b33bf95b244af4526a6f2af6afd2d96797f4b592624d5",
+ 0
+ ],
+ [
+ "00000000000020dba1c8f9785e2279ea96a7885a13124af2baeeadc75927ec9d",
+ 0
+ ],
+ [
+ "00000000000003e1975d0b4a32af071ea8584ef8b575527e2d81cac453e6a4ce",
+ 0
+ ],
+ [
+ "000000007cbf5b51fb38ed9058deb8e967476ec30fb04487496f1f7802a9eefc",
+ 0
+ ],
+ [
+ "000000000000041a6a7ad96bda602e7d528542ec9694ab3ef72908d59a660150",
+ 0
+ ],
+ [
+ "000000000000afa91fc6839e2d12370cafca0ab3679e3906a436e1d6e5ec038c",
+ 0
+ ],
+ [
+ "0000000002b73257cf72d60433d542fea3fdb0362158a238338e49d6beb109b5",
+ 0
+ ],
+ [
+ "00000000000018fe0e90bfafc82833f8993636604d963ff226d6605a3e81c887",
+ 0
+ ],
+ [
+ "000000000000028245c822181fcd9bf9e1f22d6a547fea3ca46de3a3070fc28f",
+ 0
+ ],
+ [
+ "00000000debae16db51d4ef186b18f53de6749b95fd75efe3e5f628e85cd1d42",
+ 0
+ ],
+ [
+ "0000000020703f051f8a4d4fbfdd21f7a857570dfc0617ffb7287a7b259cafd0",
+ 0
+ ],
+ [
+ "0000000000000225bc005b876850b2cdea99b82bd6a8637ecc7f45c49d4848de",
+ 0
+ ],
+ [
+ "0000000000000178dee2f53404f5a999e1c60913658ce963a671ba778ec88fb0",
+ 0
+ ],
+ [
+ "00000000000013acc2e31eb20cc2f99d213fc962710967d20aaeb2976b6bfdc5",
+ 0
+ ],
+ [
+ "000000000000042447cf56d117295a3117a19412861e7b3987846c31ef6b0797",
+ 0
+ ],
+ [
+ "0000000000001c84c583dc747fe32cac73edd483cab5a3119f68baa4110929b7",
+ 0
+ ],
+ [
+ "000000000000011875b33bcaaf4dc9535a659684fbe4c780ddf88ad607d922e3",
+ 0
+ ],
+ [
+ "00000000000017e9433e75fedbf0da7bcfeab74ad1f8ced7dff6dc2bfbd12d96",
+ 0
+ ],
+ [
+ "0000000000002cff5e0c5e2d765b92df11d26d4b308146af9c3d971945407061",
+ 0
+ ],
+ [
+ "000000000000083c1306d75a0c18b0942d0ad0aecb878e24c164a9caa3fb2ad3",
+ 0
+ ],
+ [
+ "000000008addb7a8e8081084ce3290e7f3806ec3cccf747d487f1e1540f7c398",
+ 0
+ ],
+ [
+ "000000000000048f3edca928245bf247645385b9a493abfd1b5800ad16cc0d1f",
+ 0
+ ],
+ [
+ "000000000000103e9be4a5421fbc8bfcb1551b0f24857ce6c37a1c343aeae48c",
+ 0
+ ],
+ [
+ "00000000000015a51f35436fb51d0fece5cc4e436e8a064b03d8605e47bcd16d",
+ 0
+ ],
+ [
+ "0000000000000f080e1a6f704a41308db272cde3db3ce7ecec09a95b41964e83",
+ 0
+ ],
+ [
+ "0000000000001fe64f07e3a57ba7f1f3a621ce247fbb352b80b2d0c5b70ec6de",
+ 0
+ ],
+ [
+ "00000000000026ced680e9c4cfd857f9e3620c09853402e3adfee74143bad265",
+ 0
+ ],
+ [
+ "000000000000090b4e9b558c597679770325c9b22acc7561662002db0cf7e710",
+ 0
+ ],
+ [
+ "0000000000001e1214657a7da88bd6370b3dd6f4ab346f0a8d94e4e987f87503",
+ 0
+ ],
+ [
+ "0000000000000a15c42118c002d59514deb80f9f5466d27963c04330e0e21012",
+ 0
+ ],
+ [
+ "00000000000007a242146cd528e009c348cc08eb1f95f4d670c7dc158f2bbfd3",
+ 0
+ ],
+ [
+ "00000000000001e36bbd55b594405000f74d9617538306d1a80beca516598c19",
+ 0
+ ],
+ [
+ "0000000000000039a5021d0c7f2786e5809c059d1bbc282c75f1b23aab96a88e",
+ 0
+ ],
+ [
+ "00000000fbd8390594a4ba46a5bdabdca857c83875b424d25a77b0eea3dc3237",
+ 0
+ ],
+ [
+ "00000000062932ff37ac56e6fa111466872a8dd63a40e075cf5ac17711cfc54d",
+ 0
+ ],
+ [
+ "000000000acbd85b88534f49001b7ece8fd3bb6a7d9ac1579e266c504d4806b9",
+ 0
+ ],
+ [
+ "0000000000000c69ee7072ca34b8e0de031d0e1a94ac4808fa9e0d5d54bfe09b",
+ 0
+ ],
+ [
+ "00000000000136712b53c3fa040965e914ae2c77cc575b3043ca721288720a7d",
+ 0
+ ],
+ [
+ "00000000000013bbcc926582af7d0a9916b373a8a915c5db4b7fc5384ecc57ec",
+ 0
+ ],
+ [
+ "0000000000001d67d8a253a97e007bc9b7610d837a4b152012dcf33e8ec52677",
+ 0
+ ],
+ [
+ "0000000000020090573bf3f7cecba4b23ab397d04fa40ff74fea9689841969a4",
+ 0
+ ],
+ [
+ "000000000000045744fc4904ecdf15ad85d3069df9992d4077511bc8cf476149",
+ 0
+ ],
+ [
+ "000000000137d3cf68eae26792414e81cabe328d7364b5c407e0b4ce0a2f5a4c",
+ 0
+ ],
+ [
+ "0000000000000419e67c1b77993aec325ee5315eec3fe8b4d165898e5c77fe96",
+ 0
+ ],
+ [
+ "0000000000001cf0d2c8b2717072cd15f060e622bc73414e357324c792e1aa59",
+ 0
+ ],
+ [
+ "00000000000018c48cf760d66c867ffcd0e113a4eef5b5f85d0dab65b727b460",
+ 0
+ ],
+ [
+ "0000000000002f9b1c3e3dd47a73bc9711b6f755d72514ec5d81ffb9524bb626",
+ 0
+ ],
+ [
+ "0000000000001bc7822a17596f813039ba79ddd9e114f81a3dcdc596533cb493",
+ 0
+ ],
+ [
+ "0000000006202b1fafe78be2c564ece74bea3a1ebc294497935854cbdcf2533c",
+ 0
+ ],
+ [
+ "0000000000005d7567c3851bdbc75d1c6c9372db838ad0f1e6bd472b5a100825",
+ 0
+ ],
+ [
+ "0000000002590ac2a684ff9da31826f8354865c081b243ee04abac8e799f74e4",
+ 0
+ ],
+ [
+ "000000000256d70b8e6030ee6b56ff47872fb089a5d007adb5057e7c4930d0e3",
+ 0
+ ],
+ [
+ "0000000000030d7ea2ccfee60b00002337ba48466d40bf541c27b01b5b40d0ce",
+ 0
+ ],
+ [
+ "00000000000002ea99316525bd7497de5d9fb6ae98d6e69008e4036b9f1a96f8",
+ 0
+ ],
+ [
+ "00000000000017633c6c08c6e76e58397497d88a74a77ca1428e7daf7260c9ab",
+ 0
+ ],
+ [
+ "00000000000017b937aa4b9327600dd33804deeddcdf6abfd847455c3365fda1",
+ 0
+ ],
+ [
+ "0000000000001bbbc95f689697902e961c1ae60b0ba48630cb275292128d457f",
+ 0
+ ],
+ [
+ "000000000bccd05d924bd0583dbbb9f2b09cbbd04633e2905e44d9fa9347a150",
+ 0
+ ],
+ [
+ "000000000d269ad70d5bef52c953079d690c8c20a3a9265c0dd4c37ab311f47d",
+ 0
+ ],
+ [
+ "0000000007406099654ec641983c8ba1027fbf288eb23e7352302dc0670ed259",
+ 0
+ ],
+ [
+ "0000000000003e6d99ffbf2efbfec8eac8a595214064640922bfa725967de2c5",
+ 0
+ ],
+ [
+ "0000000000002107d0e8507d9168c684c45d4ee62dceaa757890d8e34320ef99",
+ 0
+ ],
+ [
+ "00000000003493e7933f834a0f38e56e46cfd0dafba38c4d972ddb5a2e491e10",
+ 0
+ ],
+ [
+ "0000000000001be81450f65ba6bf3274e4681762d78f22827e7ebf30e6aab8b1",
+ 0
+ ],
+ [
+ "00000000000024530945d47086a0f0f15e2063d70fe5435f40796b9d754406bb",
+ 0
+ ],
+ [
+ "000000000000ab099a527c022454155a19f1c6ca6065201817400bfeed39cc4d",
+ 0
+ ],
+ [
+ "000000000000081b516b4f53c442ead11bb3e64e1d3f6fb628d3d6428f88371d",
+ 0
+ ],
+ [
+ "0000000000000d2d06a76e91d8f3e6a3634de9f1e53c4ecfdc94122b696bd4c6",
+ 0
+ ],
+ [
+ "00000000000001e9af5fb09645618a1ea84b537f0532511d921e0bd03e79a34a",
+ 0
+ ],
+ [
+ "00000000aa98879ff0193e4d5b5fccda8a680513bcf062330aec102cfddebbed",
+ 0
+ ],
+ [
+ "0000000000002bb5eaa52edd3ae8571422d99a23a71feb313866f1c177c5fa52",
+ 0
+ ],
+ [
+ "00000000037d95e70f0026b2747c3302f84668ee3e112cd335efe0b71de93742",
+ 0
+ ],
+ [
+ "0000000000fcc1b88fd91cf4cf853f6a2218f750d1f5fd4b7c3d0c4309c2d48b",
+ 0
+ ],
+ [
+ "000000000005bbb90407d1cd8864a4e0acdca910cefcecc42d90b2b80579a233",
+ 0
+ ],
+ [
+ "0000000000002f7c9303dfb193c69d0807f9bad18bd1ee5b705680afc2ec4a23",
+ 0
+ ],
+ [
+ "0000000000046dba12423dc70bff9d5c8257c5acb4bd0118d459defc904c84e3",
+ 0
+ ],
+ [
+ "000000000002b9f9c12c825135a280583c0b838b18e45da81aa44c3adc60fe49",
+ 0
+ ],
+ [
+ "0000000000001681ca0ed67c0cb2c566c238ea955a4699e281de06d36285a51d",
+ 0
+ ],
+ [
+ "0000000000003a24f300fdc9cf9bbbd23a151ee9e2a79afcb6e9414663addad9",
+ 0
+ ],
+ [
+ "0000000000000d1d80f15f3afe5c108688f0fd8266f7fe06dbb6234ee4c9d66e",
+ 0
+ ],
+ [
+ "000000008c6a6332a8c9351fd6e6af767a849eaa31838bdefefc36e8905ee111",
+ 0
+ ],
+ [
+ "000000000d2ec08ea27ca63313014891f00250ab2d0e9e8a1368541766a3637e",
+ 0
+ ],
+ [
+ "0000000002fe57f4c0d782dae857905dd16c6ca3eab6cb68f14ac96f9c5e903b",
+ 0
+ ],
+ [
+ "0000000000934f789f1d061a0f385037de57c46644929ceebf8ae7a7e8a797d5",
+ 0
+ ],
+ [
+ "000000000039aee8e147cd4bbe399ecb938d22e2b0d403d4b81ad3a36d2ebf0b",
+ 0
+ ],
+ [
+ "00000000000626939a469fdcf2109460bed2634dfc5a799b267c3053761cd78b",
+ 0
+ ],
+ [
+ "0000000000091af9d3c8ea0d3a35a15376126f4a3cda6905c7feab16db260ede",
+ 0
+ ],
+ [
+ "0000000000008fe017eccdd0f1e295efd15813211320952b9649df11cdd099b7",
+ 0
+ ],
+ [
+ "0000000000002edf8ddf0646a8e66768a7067d866d6feb3c7832cce16087d4a5",
+ 0
+ ],
+ [
+ "0000000000001f290b0b8f50d90e11aafcddff259207a8234e2764be0a6102d3",
+ 0
+ ],
+ [
+ "0000000000000039501569acd824c3e514ecc1046076c62756c850696a27aea1",
+ 0
+ ],
+ [
+ "00000000000002d87f5b96dbaa8ad6c601c886c1a5a3cab8ea2c8732201aaaf6",
+ 0
+ ],
+ [
+ "0000000050d5bb27724bd6a99a31160f8554f2e4868631c719ae0a2ec0b73aac",
+ 0
+ ],
+ [
+ "000000000feaf2eaf424a989ebdd2ba9f5ecac93ee5cd495cc9645f3af3d4702",
+ 0
+ ],
+ [
+ "000000000f79be6b3023a12b57ad8132f0987442e2e7f40d2e04d3a2699f8699",
+ 0
+ ],
+ [
+ "0000000002c329dfd4f21d40ecec041beba86df7a01c39d0775133278b88a980",
+ 0
+ ],
+ [
+ "00000000000016229b9dafbf5fabb6dca805de40ac63709bba75352e6f662352",
+ 0
+ ],
+ [
+ "0000000000b9ea4cfabe7ac942aa7bdd91689b03fbce3035cda3371e57b3c140",
+ 0
+ ],
+ [
+ "000000000b510434311994128aa067b94020fee893e67dc72998eceeb4650d1c",
+ 0
+ ],
+ [
+ "0000000007b3d6eca2c9d0a8d05c884412cfede2aede572076c7263a38d6306f",
+ 0
+ ],
+ [
+ "00000000006dd9fff347f4c9490b29544fec60a3e0504883d13ec43cea0e5260",
+ 0
+ ],
+ [
+ "000000000000e2719af18f948f63b60d06fc7f35bd7cae88ef43d2c9edd611d4",
+ 0
+ ],
+ [
+ "0000000000004ff5733da92e83ad0970b7eefa8719ebe7346f65c8da62be79e8",
+ 0
+ ],
+ [
+ "0000000000000247d49cdb1e1ce982cfafcd8ceedb9bb1090d2cf8dd52fd7362",
+ 0
+ ],
+ [
+ "0000000007aab1ae7198606046c23cb4616edafd19c0410660dba6ef065dc522",
+ 0
+ ],
+ [
+ "000000000d6a048170bb18c0421b153bf709cc85e090602c2a5b0a626dbcc3f4",
+ 0
+ ],
+ [
+ "00000000069a824155cbf3f02f60363bf5b41ab7e8a83fe647fc4169b34f2808",
+ 0
+ ],
+ [
+ "00000000009d32bba8dd11262ada78187ff56417fa1df97b3444196c987683f9",
+ 0
+ ],
+ [
+ "0000000002468dbde6d879e2027447918f4bfeba25ff32a4f19edd953d42aba8",
+ 0
+ ],
+ [
+ "0000000000e92e9a687fc2fd40b221a02f76ad72228503660b5b6702db9757a2",
+ 0
+ ],
+ [
+ "0000000002549c36360bcdf2c495f9093f117ac4a28e205d954ec803dfe7aa57",
+ 0
+ ],
+ [
+ "0000000000e1deed95098ca573091dbb6b696787ba265277bc2ff65ae73cfde0",
+ 0
+ ],
+ [
+ "000000000bfbb8b7a1941598009350c5d741e05c6575323591a04d57c206e205",
+ 0
+ ],
+ [
+ "000000000dff311ba823e48522c286a4962bf9e9bd7a0dde66aa5e5d3d9796d9",
+ 0
+ ],
+ [
+ "00000000033b0dc90fb39c5c46e144dd08a52572967a712988a7b543195185c9",
+ 0
+ ],
+ [
+ "0000000000003830feb05937c05d8250b9a465c6a5d29dae49b127dd9abc99e4",
+ 0
+ ],
+ [
+ "0000000000014d788b90d6a52d49e8e74a054e26429c25ef8a4f3cddf9463e6a",
+ 0
+ ],
+ [
+ "000000000000007cf539b80a7e7f7d38a511f7c3fc94161cfb3b960d70c54359",
+ 0
+ ],
+ [
+ "0000000000003c7c0f340bbd67e0dc9dd89a340829de0c4a3f1d53db38499a79",
+ 0
+ ],
+ [
+ "00000000000017e131be27185a58f3f9f0e239b1fd15571f500bc01aabf3640f",
+ 0
+ ],
+ [
+ "0000000000002f688fa71606e8feea09acf4d97493f71adab27bf782c1c11658",
+ 0
+ ],
+ [
+ "0000000000000b38da817e3cd6a112626414b5a7af0cd8dede33f8facb87e58c",
+ 0
+ ],
+ [
+ "000000000000020f5ce46cceddf4b42457687c20c2fba80ec2c797354ae32deb",
+ 0
+ ],
+ [
+ "00000000000000b1c5d067792aae5ae74e4cf30b0f28caa064b5d736b2b9ea08",
+ 0
+ ],
+ [
+ "000000000000000bd96df6cfb597259a34b8abed672dc344c6de48c4c26e4b6d",
+ 0
+ ],
+ [
+ "00000000000000061b79bafcf1a446752df3217b60b83bdd768a3030f04b21bf",
+ 0
+ ],
+ [
+ "0000000008f3bcc3504c464581b257b1236e0bfed8b0472b58281a161fd1287d",
+ 0
+ ],
+ [
+ "00000000000019b757e0f7768982ecb439e31b022efd60bfb09509e2c920d063",
+ 0
+ ],
+ [
+ "0000000000001e5a69bda72ad2c5f4bea6018f11ec4e3bfb6f974e08c81814b9",
+ 0
+ ],
+ [
+ "00000000000002bdd9c3c6c9359582934a594ca82d6c9104f15277ed10349bb4",
+ 0
+ ],
+ [
+ "00000000000015b7c29f74c5dad9d6121d0d4d3af9f44333b2008fe5bcbe2eed",
+ 0
+ ],
+ [
+ "0000000000003638e630bf33aedb0b0152837f695224c199ef96c248a6b22f16",
+ 0
+ ],
+ [
+ "00000000000011be4e7ceacd148ea83abee5b61063cf679c879d0e13d0a40655",
+ 0
+ ],
+ [
+ "0000000000002542493ba1cf5396d5209244f266a2c5764d4bdd843c5986d82a",
+ 0
+ ],
+ [
+ "00000000000039197e58f1ef6c98eee126a41ef156036ea51114b15708401701",
+ 0
+ ],
+ [
+ "00000000000004b893eeb6b43cf0723b5f0fc2c6e245e6671f543421ef8c5f69",
+ 0
+ ],
+ [
+ "00000000000024bc586bca1c86f4254b3d74666f22ba2f5742f588d7d50ca6bd",
+ 0
+ ],
+ [
+ "000000000dd0843d947ba235aaf2cb1b5e2320f1b15d1f79d32ccb01040735f0",
+ 0
+ ],
+ [
+ "0000000000002e9524d79e1972ecece3c432eae5e8c2aba1e5d61584c8b3c780",
+ 0
+ ],
+ [
+ "0000000000003c02f0d0743d391caac03c631ebb5518a7e4261487482586587f",
+ 0
+ ],
+ [
+ "00000000000015a72bd8520f0d8fb9d7d658d0e62cae6830bf94a2efdfd85e65",
+ 0
+ ],
+ [
+ "0000000002f4dcab6bbc94ae9a1b0761ab7b52cd56272b6183715fa1a959101e",
+ 0
+ ],
+ [
+ "000000000000005ca13d4fc8bebb498e46dd764ec5f727ffff7ac9c90da8ed28",
+ 0
+ ],
+ [
+ "0000000000003ba5816aaac8c6efd085bc08676ff2b51e7c3d94263b4a555c17",
+ 0
+ ],
+ [
+ "0000000000000881711d6afcd9ad84823ef6cce98b1cd8a90422867f23644a37",
+ 0
+ ],
+ [
+ "0000000000003d627ca2ad44d333543e86cc377e63ca507ddea5aca7ab4a0829",
+ 0
+ ],
+ [
+ "0000000006ae50b5cb9404746f3702b2400a3743a05893cbac1b372aeb122834",
+ 0
+ ],
+ [
+ "000000000000197307176984fc5e3b612c9b6a89510e5a3604711f90739846d7",
+ 0
+ ],
+ [
+ "0000000000001709130852704239c0328a00d406ef2914dabab52a137bf59aaa",
+ 0
+ ],
+ [
+ "0000000000002d8039783ae0208445429241d2f5b4485192e1c50f6921579daf",
+ 0
+ ],
+ [
+ "00000000000028ab4cf7ef62580408801e9c4a3b955f6f1dcfbf339f08f335ac",
+ 0
+ ],
+ [
+ "0000000000001730f6260b57593f30c6a24ad49f5afde41daf8dad5bb83cd737",
+ 0
+ ],
+ [
+ "00000000000026800054645b160790f1d226f87a2fd809acbe06d018ffc13fae",
+ 0
+ ],
+ [
+ "000000000112993137d21171ba742be3beef4af19eba815f326fd6ecbf54b547",
+ 0
+ ],
+ [
+ "000000000000236d85b62dda3bfe9850e4e523e3876684461b7e1107153556a2",
+ 0
+ ],
+ [
+ "0000000000003fe1c0f4a23adfb74f07bbd4cfbce44db35119332a5c43500bf8",
+ 0
+ ],
+ [
+ "000000000000381ff5b96685ca16305b427c4a1dcba2d410e6ee0598619f75f9",
+ 0
+ ],
+ [
+ "0000000000001d8eea8071cc6e93b6dd058924a6fe171438974c304374e1f1cf",
+ 0
+ ],
+ [
+ "0000000004da6faf93f8594e82f9d282d95583e72f79365083c00fc2f4fe249a",
+ 0
+ ],
+ [
+ "000000000000254e1d80fc91204b555a4269d18d892c60a845c907175fbc018d",
+ 0
+ ],
+ [
+ "0000000000001a945f9ea3b8d08ea7befd5074bacd9732a05d1dda579b197538",
+ 0
+ ],
+ [
+ "00000000000028ca1712b95cf77145ff45e9049c5c39458d18223b3e17812d60",
+ 0
+ ],
+ [
+ "000000000b06ec0c9b1443bfddc2b3376e71789298ebe3575be1b0f5a34a37f2",
+ 0
+ ],
+ [
+ "0000000000006562376c1164d7b6f5281d64fa7dd0112bd34e4ec9c7537698f6",
+ 0
+ ],
+ [
+ "00000000000027ab842f086d6c6691aca8f51967c1c88281a4e49ad89878a806",
+ 0
+ ],
+ [
+ "000000000000ed2292a4f779dcc4ea3315b855243a3b70804da3168377f408ca",
+ 0
+ ],
+ [
+ "000000000000c287b24e25768d8524cf3d8b892da291f868446cff373f878777",
+ 0
+ ],
+ [
+ "000000000bf32a8711ff9536b7ccece30a2d6ac40538123206862e01a8ef2350",
+ 0
+ ],
+ [
+ "0000000037bfd22d6171e4f1c7c7cb1cd73b91a19a3cb473bd9f33335150e001",
+ 0
+ ],
+ [
+ "000000000000045f2524a3a3faee2a2d36cdfc9531f429670106622d0c1bb558",
+ 0
+ ],
+ [
+ "0000000000007e262915f1b1ac2bbfb97ef84bfece7bb8ae3340fe4eafe7017c",
+ 0
+ ],
+ [
+ "000000000000727ee42b831c566ed9c2ad92e9b14db0c8058b9d6d6066752e71",
+ 0
+ ],
+ [
+ "00000000000002227876ff70290cd31e6b08ecdbdc2dd34ff0c69412ad863f4a",
+ 0
+ ],
+ [
+ "00000000000013b9729fe455d8409547ba3dd30d62eb1661ce6a38bfcd358d02",
+ 0
+ ],
+ [
+ "000000000e61c29a6e384bb7f4685608774b790cb7dcaea51fb99f8a10e2e03a",
+ 0
+ ],
+ [
+ "00000000075f63db5180b1afce047b919bd4c06cd81d6dbe5224189cb6fb3e51",
+ 0
+ ],
+ [
+ "0000000000a83265ca1a81e9d610bc6de821e07739c89a137b93812b545f5488",
+ 0
+ ],
+ [
+ "000000000003ba66b509ba5ab7cdab0c705c8b4145e1aade97b86e0f8a1ec6c1",
+ 0
+ ],
+ [
+ "000000000002b0ad2b5fd6f15946ba21f819f074af0b96cbcd9a58f5be2ff6d0",
+ 0
+ ],
+ [
+ "000000000000103e6ffc413e63a85bcbba243a9c2acf45b433d6dc36acb6ae1f",
+ 0
+ ],
+ [
+ "0000000000002226a204b75f5086a8ef6576e8e79af7e904bc9a75287d518b81",
+ 0
+ ],
+ [
+ "000000000000738f4fc499a14bcd7bca5c16273f59d7695decaa9b177cea0873",
+ 0
+ ],
+ [
+ "0000000000ec7ebd6c07b47fdcdb2d924e0659a22d2b2bbc0f5baa15cf940825",
+ 0
+ ],
+ [
+ "000000000002c9d4f581fb0fe2f40a9a24db42de1a85064b4a304a25338a62d2",
+ 0
+ ],
+ [
+ "0000000000003ede1becaaff56312310787669e07c38decfb43fc6df9f7950d9",
+ 0
+ ],
+ [
+ "0000000000f76fd9b371fb6231cbf696f0e02967dcbbbbc32558b08a6bdf1c0d",
+ 0
+ ],
+ [
+ "0000000000009c6e722f7908de61abcf6edb2e530e13c97b4b226a2e1dd9cb27",
+ 0
+ ],
+ [
+ "00000000000008730a8476c058a573bc8100dff1da03b3d942a8164a107a00b0",
+ 0
+ ],
+ [
+ "000000000a2ff7a14ba1fddfe3239d3202f310490841a5d1294d53df83a471af",
+ 0
+ ],
+ [
+ "0000000000a5584c9d4f74046f9fe4e3c9080a0bf3025330065b9607c2142a2b",
+ 0
+ ],
+ [
+ "000000000000d55a8cc2e86b72a1a0502a25962b257e0cac2203b79270dc54ae",
+ 0
+ ],
+ [
+ "0000000000a2f8ff2e196c0def66a352622984d03216ef0610b17813ca4accfd",
+ 0
+ ],
+ [
+ "00000000077267090ddd0a5287bff41a19e4aff8772befcf9f6147d5dddc96c2",
+ 0
+ ],
+ [
+ "00000000001c56edcba9d4ae7b7372bcc7f8ed6db3c3f7fcbbed555028cccf27",
+ 0
+ ],
+ [
+ "0000000000009c304f5a5cc26c3bce7378205db954646d85049dcf77ef98eead",
+ 0
+ ],
+ [
+ "000000000000987179da28a7bbe0cda559b26e25249d826406f002c65a5ffcf1",
+ 0
+ ],
+ [
+ "000000000c08930caf906884d095bf68c98781a191bdff9b8677fe25660173e8",
+ 0
+ ],
+ [
+ "00000000000070168a5836c8e1676f6b9fa8e316fce6c38b91d6ce7226e7e897",
+ 0
+ ],
+ [
+ "0000000000001d7cdc82f066c2adf9b6b8658d531a6dce864bab36660c7e7518",
+ 0
+ ],
+ [
+ "0000000000000213c8fac899b3ceae3b36a66a63e94e570b3642c8bc4459fea9",
+ 0
+ ],
+ [
+ "000000000000803753b883992776bcd9b0f4c3553ad19e524e82362180a777d5",
+ 0
+ ],
+ [
+ "0000000001286d5f36e3d987a6d6ee4c2990799738733331b53e1606e5db55b9",
+ 0
+ ],
+ [
+ "000000000000d656dbf9b01aa7cd45bbc7ca206e5fdee253c1138c8d01dde543",
+ 0
+ ],
+ [
+ "0000000000004ed5d68da0abdc500801ecbcb13c2a0d0cd22c664c48cf1fea0e",
+ 0
+ ],
+ [
+ "0000000000aa80295620b2da59ab5af04caf0bc3a91660a61458b08d9a0ee5b8",
+ 0
+ ],
+ [
+ "000000000000f9389fa442251d5fa38a34a628b26d3dc573da88427c0e330067",
+ 0
+ ],
+ [
+ "00000000000072ab2052ab08cad2d2e73a46c385a828682335828607d9237d09",
+ 0
+ ],
+ [
+ "000000000000f6fd78689cd91839bad209db4d373546fd657801b00f3186fce5",
+ 0
+ ],
+ [
+ "000000000ef3589b0434e77221be33e79b0751e3063b0d0ee03bd70ae43adee5",
+ 0
+ ],
+ [
+ "0000000000009c9c3db06deb8bc77fab2765817b2293aab7393e527a5ef33e5e",
+ 0
+ ],
+ [
+ "000000000c17c570e13201f910b29ec68a66a21f4a2ed7866987905012c1330e",
+ 0
+ ],
+ [
+ "000000000000095a4b6c94eaf359cbc8301ae2568927eb9b151266a0286748a7",
+ 0
+ ],
+ [
+ "0000000000004cb69211ccf74e6d84ccb6dd421c8dcf16795e8d3b476ff7473e",
+ 0
+ ],
+ [
+ "00000000000006db56d24caed9018565840fce8363a7cd9e912504114d0b4c3b",
+ 0
+ ],
+ [
+ "0000000000004a15f1dc5253af6641c141a8f4566ac14fac2842ca6ba53bddb6",
+ 0
+ ],
+ [
+ "0000000000002aa51c0812c9a1438b0c3bd8e95330a8c4fac62439d2797d168a",
+ 0
+ ],
+ [
+ "0000000000002af6ab6d23bf33123e2393c1c9df7e7c2e4f722aa130b0f0db97",
+ 0
+ ],
+ [
+ "000000000dc6e875c0a047d3d05dd885c93ca1de0d9cd1a08ee6523e321ca9eb",
+ 0
+ ],
+ [
+ "0000000000006a0b4e485b2ad027de8edecfa97a822a87e9623b21be8353cf91",
+ 0
+ ],
+ [
+ "0000000000006ccdf09196936d0cf74da3a3c433487cd13e3386bbf0e84e64a6",
+ 0
+ ],
+ [
+ "000000000000134acb738b7e99f9991d63519e28da5a1c50c60a31b596f39034",
+ 0
+ ],
+ [
+ "000000000000bb7e53229e6557a53be46d98a9dbdc4c827b2b094492d775d1a6",
+ 0
+ ],
+ [
+ "000000000000e8f54f39df60e09d81653a5f80707d084edcd6e8bfdb7427c588",
+ 0
+ ],
+ [
+ "00000000034b27b5cea0fe55f4dac79fc32e25b4a09ce643f99bbad2e30e9335",
+ 0
+ ],
+ [
+ "00000000000042edd05861e88b20e03ec95a7a5ba8f60e595ac044d77ac5198a",
+ 0
+ ],
+ [
+ "00000000000021bb194f73f614ab1f4216227f09789edaa2677da691ef5d73bc",
+ 0
+ ],
+ [
+ "0000000000009b2dea9b3b6c8025c7e632fd7918316400a79170d7114fde31cc",
+ 0
+ ],
+ [
+ "000000000000b6e17bb1f2574590815ad6d20456e9d7f219221c58e3aa3767fb",
+ 0
+ ],
+ [
+ "0000000000009c762fd6211c015e5c6a42723136e950cb2333d30504ca2b0173",
+ 0
+ ],
+ [
+ "000000000000733c889bfdba15a7e9880890a8ae6814d4310ee6896e8e7c1dec",
+ 0
+ ],
+ [
+ "00000000000022d2fcb2ebb92f8cea3f174969805f69de7518ebd8bf26140255",
+ 0
+ ],
+ [
+ "000000000982ba146e85fa32f2c3ecc0321d865a7960a6cda8928983ea679b0c",
+ 0
+ ],
+ [
+ "000000000000e004dc4ba124a02643e3a2cc4f36665c75d97c2590be07d1e304",
+ 0
+ ],
+ [
+ "000000000000a7d0dd7994be5e5acd32495145008362e072eb02b297e1431207",
+ 0
+ ],
+ [
+ "00000000000011af0549ad5774dcb338f3c8e6757ff7ba2bf4da4f6932651862",
+ 0
+ ],
+ [
+ "00000000000016a682c3e571c0fa6f982f5193f4e416f474c8a7ddc79d88dda6",
+ 0
+ ],
+ [
+ "0000000000009d072bc459efeef121a836c353d88a8dea2e73ff17c2f2933c67",
+ 0
+ ],
+ [
+ "00000000000091d01b9faa03a2f5076d194b5f889425e2a85b2064aa4b4a8258",
+ 0
+ ],
+ [
+ "0000000000004b035f4693d1d32e518c7977a8ea2e77b7b6f4f51b10763ff505",
+ 0
+ ],
+ [
+ "000000000000c330f00b190b1ee5e60aa8cc3871472a02d98bade6dada6351ed",
+ 0
+ ],
+ [
+ "0000000000003f970f8df4843d6a66b74793b5b7ff3bfe0dc128f6eb00b242ed",
+ 0
+ ],
+ [
+ "0000000000000a649d33eb372d40599bdc493d6baa50cfdd667073897c09487a",
+ 0
+ ],
+ [
+ "000000000000026b4a6c9b6241aaa2b3f5927e6fff5fa0f2c2fdfb4ef39f00fd",
+ 0
+ ],
+ [
+ "00000000000000789d9b77f871481ec9cd1b8b877fe885c49eec83e97df988d3",
+ 0
+ ],
+ [
+ "0000000000000028abd92b26a2328ff5bf15d3e9104d2491d23d5f789bed2c1f",
+ 0
+ ],
+ [
+ "00000000078b55bf69f546804a25e86ab88ab12b3aa0d614952c23e82f7b2c99",
+ 0
+ ],
+ [
+ "00000000000030ca4bda5ab3c07f5476eacb61ed54cf7080b3ce112efbb0703a",
+ 0
+ ],
+ [
+ "000000000000175f0ffd64634d958ebc5ea9659e5b9c0e91c2b31cbc9db3a8ab",
+ 0
+ ],
+ [
+ "000000000000355f3794e3a81787699b8fd649eded08f829d1e6e47e5f0b9daf",
+ 0
+ ],
+ [
+ "0000000000002e5396ffff0cb008c6a5c773ac7b56456f3574442ff22da9c8c8",
+ 0
+ ],
+ [
+ "00000000000013f7a1e25a9fd016d1ec5abeba0dc69d1af2b656e86f19fc73bc",
+ 0
+ ],
+ [
+ "00000000000018cf2326052e78cf74dd3671c63bf64b58c5dc4b1ac964b712fd",
+ 0
+ ],
+ [
+ "0000000000000f321300ff44eb5bc2806eb15ac3e73ecd8dadc08e00921d35a1",
+ 0
+ ],
+ [
+ "0000000000000b3af62155af09d7d3612eed2522fbc7f9eb1751c67c56e20903",
+ 0
+ ],
+ [
+ "0000000000002cbf583243f17ff8b6ad7e4477d3e58e41cb6589882fbed15406",
+ 0
+ ],
+ [
+ "00000000000001b336201e7d8876fde8def3923ceb44bad051cde464431d4937",
+ 0
+ ],
+ [
+ "000000000000000cf89bb1e492b07c8e771b880995d293bbb5a2b6312d9a1584",
+ 0
+ ],
+ [
+ "0000000006b21f9e08db1b242dec441df0a12a8e5b93d5fb39fe6069cebd197b",
+ 0
+ ],
+ [
+ "0000000000002d81bfd983a54e8fb65d48868e361c5524fed9646bd999dbe894",
+ 0
+ ],
+ [
+ "00000000011b6d38162fa8128f2a69ce0ab991bbbc619482312665414d32ff2c",
+ 0
+ ],
+ [
+ "0000000000003ae808923e1323e4790f207895a1e04353a6dc5c74502401ca17",
+ 0
+ ],
+ [
+ "000000000000253ef210a4ee95c1950a3095825114dd67ea0fb7a7bf0f75006c",
+ 0
+ ],
+ [
+ "0000000000001c6a7b8d2fa299c8ed9433e00defee0f97b5d93427afc753edeb",
+ 0
+ ],
+ [
+ "0000000000002c54c1b035f0d6768d24899d2c79933aba096074302075389ec9",
+ 0
+ ],
+ [
+ "000000000000373ab0c2c8fddb53cc8c1890448d11deb2abbb3e36c986d2cb17",
+ 0
+ ],
+ [
+ "00000000000031898465c27a7055012064a6184c41f8cbc89700419b802744d2",
+ 0
+ ],
+ [
+ "0000000000003aa31991360c4b63ed0760d83ea3eb3279789b406498db586bea",
+ 0
+ ],
+ [
+ "0000000000000e5f6c671e63b383a90347b7d95313fe06570e5641442d30025a",
+ 0
+ ],
+ [
+ "00000000000000eb1edebd17248bc5dbdb02eb7010fd41eb425a6c78975dadea",
+ 0
+ ],
+ [
+ "00000000000000c2d84b76a602b417861cfe4580194ea4076405872b1e954808",
+ 0
+ ],
+ [
+ "0000000000000013d788f8faccb96b3d702a1a2a94f547be4552946cb04d6b02",
+ 0
+ ],
+ [
+ "0000000000000007ddb93d3d5d2cf8521ad8d8bfeaa031138e4271669a0000b6",
+ 0
+ ],
+ [
+ "000000000a43bfdeb9b9f513518ed0b4a523075c3ead0b049913821466f40624",
+ 0
+ ],
+ [
+ "0000000000001b9b9cff2195dc6868da50a4ae6eb4f3661f31feeeaf2a5fed7a",
+ 0
+ ],
+ [
+ "000000000000346a1dfdcfc23f8bf474030c42683288ef5e6419eac5e55720f4",
+ 0
+ ],
+ [
+ "0000000000001274395c0a6d1b509d8b242ef570c99167f388848999c9d00b87",
+ 0
+ ],
+ [
+ "0000000000000684d1b654d0c2a3c9fc1919d2f20948106d48d2365bf1d52d11",
+ 0
+ ],
+ [
+ "0000000000003d31b55f30b2d91979b6c4aa249ab5e782bfd61c6cd99a199962",
+ 0
+ ],
+ [
+ "0000000000003b4e4f3fe1eb3f17922cdff9538b862fdd4be332b38825132504",
+ 0
+ ],
+ [
+ "00000000000002b62a4334c696eab73339ab3cc497716b163beaa5c568be70d3",
+ 0
+ ],
+ [
+ "0000000000003b0a1b1ddad166962bd5d29335590c53ef5d6079fabc90e238d3",
+ 0
+ ],
+ [
+ "000000000000289da5bf12022ca27bf0b8b1f097a9f1e612302a64714f737c35",
+ 0
+ ],
+ [
+ "00000000000001b05c01730ed74a9dfd86fdfcaa28f97f9128867b5d6dddaa87",
+ 0
+ ],
+ [
+ "000000000000016a9af16f51a69f5ca833eb52c97cb062c4255f97296850f8a8",
+ 0
+ ],
+ [
+ "00000000000000576c2c704125e6704e9e1b1b0b5269f0ad7e14291f8bb875ba",
+ 0
+ ],
+ [
+ "0000000000000036675b4282e91cbcf341e0b5c68085b5c002f7c623afb01506",
+ 0
+ ],
+ [
+ "000000000000000029531ba2b1f27a4a1ac57a946ef322848417ec83d7cebf96",
+ 0
+ ],
+ [
+ "0000000000000000764a389696612dc41f5ac2c5ecebb887e296c226303754d0",
+ 0
+ ],
+ [
+ "000000002c3a2e26f7891923807fe41d14ff232de4a9496bf99afbc53af94006",
+ 0
+ ],
+ [
+ "000000000a27812c8ea52565338cd4881b783d18f0d4bd9a7ba8f4ea4b8d1e6e",
+ 0
+ ],
+ [
+ "00000000000044c58e6a1e82f51dc23b11ba4b6c4eed7ad317654198e9a51022",
+ 0
+ ],
+ [
+ "0000000008c67913ebb49ff07a2776c081113fb20adb56b1f8c8fcd1094721f2",
+ 0
+ ],
+ [
+ "0000000001cdc1f92512c05d201fb32ea48b23e1d2568899ab06f14ffa329848",
+ 0
+ ],
+ [
+ "0000000000003eef51183eb12076427c8c223ab73760373c163bb4da036f7cf2",
+ 0
+ ],
+ [
+ "000000000000743bc379610841698c4d333e5cc721b8ad95d027b4231a365b1f",
+ 0
+ ],
+ [
+ "0000000000005e9274e286a066e145689530db6183a175f8e6721c44171cdd16",
+ 0
+ ],
+ [
+ "00000000000021a56fbf607f3805c158393c0d8b3683633afb0d0ac7d64d0496",
+ 0
+ ],
+ [
+ "0000000000001e223329a43536dfaabc1467f2ad7d44e55478f62fd80d9e48b6",
+ 0
+ ],
+ [
+ "000000000004c61719fe71b56a2d8706b5119e2df77da7eb9330aeda1825fa4b",
+ 0
+ ],
+ [
+ "0000000000001e89565a9c898bd51bede84e2f231aa29a38f3b47b987a2ab0e1",
+ 0
+ ],
+ [
+ "000000000000712743a8df968020c48e3955170166e90ee9a2e02cb460349ddd",
+ 0
+ ],
+ [
+ "00000000000019192acf1ff42fd9fd8d9aac23e51837f4698a8f02596f6cb7e9",
+ 0
+ ],
+ [
+ "0000000000000f4f49cebf0d2f8731f5136903bbe9c8bde1b1ca94048119fd3a",
+ 0
+ ],
+ [
+ "0000000000000141958bef54ea397bde360d8ac3eb117c1dd785f8006856ac92",
+ 0
+ ],
+ [
+ "00000000069650f051d27f2cb54045c19304eb99425c042e010ee982daaa9002",
+ 0
+ ],
+ [
+ "00000000000087523d9819f2661f47140dda70be7ae7af0b2e75324dc3d6fdbb",
+ 0
+ ],
+ [
+ "000000000000384f0be3649578b7ff6b578e5976e18f79829e5a40c975f54ca6",
+ 0
+ ],
+ [
+ "0000000000001a9f45b1b809fe1000a7177d284475c0db05c0bb18645e1f04a8",
+ 0
+ ],
+ [
+ "00000000000374404f82c755ad76a64a96ff4ff449c365ed7769ed50437bb09e",
+ 0
+ ],
+ [
+ "00000000002b4291a859b5489268c232ca7f86f76944690b668d82b94538e0f8",
+ 0
+ ],
+ [
+ "00000000000aa4227550ccf273d27412aba102a2a90520140b715c95fb4822c9",
+ 0
+ ],
+ [
+ "000000000001cad06fc2b9a27871b4e6b8c643d6fb3dc559073c2e2aa51ea4cb",
+ 0
+ ],
+ [
+ "000000000000ac7c3ceff73b328f172c1e1906f0d706885cfaa4f60b6bad42c0",
+ 0
+ ],
+ [
+ "0000000000002c0c170a510d880aae66dc7beb2ff0f4d5db3effd802e0a2e89d",
+ 0
+ ],
+ [
+ "0000000000000db55eafce7adecec679b6793d81a60f33c19a7e9055ed47ca41",
+ 0
+ ],
+ [
+ "00000000000003201aa00eb05c7b2bef9b4972ebc640a04be522fb749a12afc6",
+ 0
+ ],
+ [
+ "000000000000004faab34097be7f67a51b3ff1e7b2527d750697691b2753a996",
+ 0
+ ],
+ [
+ "000000000fc33ffa85423a62215dd3707a31dd5b13f2b9ba868060670d5c456b",
+ 0
+ ],
+ [
+ "0000000000030b22b40f979c6104cdb59113cf76a15fdd676317c721c1c9a4ba",
+ 0
+ ],
+ [
+ "00000000000096a0202c2467783d606246faae84a897c96e5c05c42602d2bd72",
+ 0
+ ],
+ [
+ "0000000000013b2316c180c52c60e4f38866430e94e46d2c7f0b63a6cad57616",
+ 0
+ ],
+ [
+ "000000000002bd04bc3dbd8a3a91e1d658ee83acf1a7a66f1c0a5cccc70e88d5",
+ 0
+ ],
+ [
+ "00000000000026d791eacacc6c13689a83e057c77cf2986a3da3a43e1c6924ba",
+ 0
+ ],
+ [
+ "00000000000a03a783e832ace54ae9a71ceb9888102e1f91332005c5ef5dd139",
+ 0
+ ],
+ [
+ "000000000003d078e9ac9a2a9cc3b31190e1ce78bcc449a6b471ccb4cb6fceb7",
+ 0
+ ],
+ [
+ "0000000000007d94fd9860466fc526d3f9b327ad9653ac7297bfb9991a2fdca1",
+ 0
+ ],
+ [
+ "0000000000000f7bfcc5d12df9a0989837a98d371f495627660321ad857912e5",
+ 0
+ ],
+ [
+ "0000000000000303be9bf59b1161dbf359d6c65d99a38eb03619428d0d0c090f",
+ 0
+ ],
+ [
+ "00000000000002dcad26269a9f62413ec0a71f0fb228872030f1617cf634fe3c",
+ 0
+ ],
+ [
+ "00000000000000bc9f405c6a6088973fae16ee48061ab01e97c8e4193c88509c",
+ 0
+ ],
+ [
+ "0000000000000040d4a88b68cc37ca890f3ab23958f567253ef2ce87eaa36bb0",
+ 0
+ ],
+ [
+ "000000000000000f961ed7d57a81e0c232669ff9e8ccc4867632545016c78ae2",
+ 0
+ ],
+ [
+ "0000000000000003c996d48410bf2660521bfa1151fd316d65600f72c4adf778",
+ 0
+ ],
+ [
+ "00000000001c7e8824a780a745fd728e6106a49a50b19b45e0653fccdde06b90",
+ 0
+ ],
+ [
+ "000000000000094011e26b89dfbd3355d001db8b07b29474a5ced7b5883ed2cf",
+ 0
+ ],
+ [
+ "0000000000002ed4eb9f36f5bad0ac0f0c4c0e6a0b02d9e6267472f0ec96a06f",
+ 0
+ ],
+ [
+ "0000000004fe1536748aef5d7286f1699f4cd0f8afc845aae9ff90435e81713e",
+ 0
+ ],
+ [
+ "0000000006d789445291bccf72dd7f126f3e1d3d03319df61683079826b94f89",
+ 0
+ ],
+ [
+ "000000000dfaf0283c12eee49338b0fd14181a50302074e51d928ce2280b8814",
+ 0
+ ],
+ [
+ "000000000117b2c05c27806291a5596c1d65e784ca67e886701df617cae96bc1",
+ 0
+ ],
+ [
+ "0000000000001db99ab74f6cae68d350a095d5967636070247363b6b2f9fe87d",
+ 0
+ ],
+ [
+ "00000000000010047f90a66416b399f265a55d9dbeed30cd320ae2c1cfe4ace3",
+ 0
+ ],
+ [
+ "00000000000021366ac9446eed3b71b3d437ac3fdc6647dafc52803fa8b1e920",
+ 0
+ ],
+ [
+ "0000000000000dec6efe565871e1b9212c8b6463cc644c4e79e32ac2758bf4a9",
+ 0
+ ],
+ [
+ "0000000000db56b3d3e67e1581d443fbf695834694b7af2c7d9f9e96f017659f",
+ 0
+ ],
+ [
+ "000000000af313478dc82a2e3a71147d678b41a83149cd0c1b0016c796e666f6",
+ 0
+ ],
+ [
+ "0000000005e838486868ee22a713d261e0bab20414bd5805fd914075906e27e9",
+ 0
+ ],
+ [
+ "000000000000343b7029da5dcf93316a95e2b83a34647370529efb7a1f3fbe96",
+ 0
+ ],
+ [
+ "0000000000002797189eb8edebadad2192491f56fc93ab08424e5e00161c3fca",
+ 0
+ ],
+ [
+ "000000000000948cfcdc2645c13d25093500954bd8c49fd3534f695e31d0f383",
+ 0
+ ],
+ [
+ "00000000000015db4d59d0de0af7880fb9f710f20909ec39c42eeb49e96eb5cc",
+ 0
+ ],
+ [
+ "00000000000002ee9a047e4a3123d527298be5eb4a1b4280b98937f85ac908b7",
+ 0
+ ],
+ [
+ "0000000000009260261421c980b8cf2dcddc6a18c49bd1bc804dfa6f0df359bf",
+ 0
+ ],
+ [
+ "0000000000003b963afa52c2187dee9fb9b2cceb799994f7e71f4aefe9255e84",
+ 0
+ ],
+ [
+ "0000000000000d9d821f0edd2940e911b16ffb7d21ef2f43b089c18f0152c1d4",
+ 0
+ ],
+ [
+ "000000000000004117fdabe3fad201f486169db3445064231bfbd78a74fa7f3e",
+ 0
+ ],
+ [
+ "000000000000007ec434b48b3414447b10656380c1ad15ca0caedeccf95c5cdb",
+ 0
+ ],
+ [
+ "0000000003c5bf907909fc3656595f18ec47a05c4cfdf3b682d004a333a1d9ba",
+ 0
+ ],
+ [
+ "0000000000ac007e356a1bedb70fe31ca56a970f746e3b65d6f54d002b4d439a",
+ 0
+ ],
+ [
+ "00000000014b2c8f10765caec75f6fc14888d231b1acb22307f7a13c926e28db",
+ 0
+ ],
+ [
+ "00000000079cea540986fd74da98751311b2735eb07a745b7c683eaa3f15b830",
+ 0
+ ],
+ [
+ "000000000d27fe2305af6080e0d5eee7344b0489cb1c0da19e2d9c537f698126",
+ 0
+ ],
+ [
+ "000000000a1ab2ea2304c980c0e84e2920317b554e638cb5da8ed1a6bc6f30e2",
+ 0
+ ],
+ [
+ "00000000006e744c182c88b16e773daaa57c890c3bdfa3ba32590acbecdee7b2",
+ 0
+ ],
+ [
+ "000000000379c716212dcfba61597cfc096cd8c73d38b1fd3e2ee5e10eec7776",
+ 0
+ ],
+ [
+ "000000000000f0051043df801de031dac8d58b7309a586ad22f94467e8192532",
+ 0
+ ],
+ [
+ "00000000002354f15017f05b70a8d4cf2ced4e9c699e4e729cee091579dcb332",
+ 0
+ ],
+ [
+ "000000000004dd9488c9451e76542f6b5100d0b01998f9c50691916c62b32d76",
+ 0
+ ],
+ [
+ "00000000000297f0a1b8437c1c361cb06c230a7fca5a16cc10fc5a9fd5444d3d",
+ 0
+ ],
+ [
+ "0000000000003dd884b7190ae6d8535515bb332b4726e45fbe9309e12bd824f6",
+ 0
+ ],
+ [
+ "00000000000004aa0500e9668e48ce227ec9d9ae2f68fac9edfea161c2f653e3",
+ 0
+ ],
+ [
+ "0000000000a0cbcd8a3d3106851c1f0bb80890c8040b8204f9b89853f646b800",
+ 0
+ ],
+ [
+ "0000000000399d0689f2c25d5b15321a28e3ff7a4ef283522b8cbafb9b2d4015",
+ 0
+ ],
+ [
+ "00000000004b2624e3a7089c4e438e32193f42a7821455e9d3bc82d525002eaf",
+ 0
+ ],
+ [
+ "000000000b219e11ff7d9ca4bab7bc32b30cf6c07fe8b757bba4f1ce7a4ba782",
+ 0
+ ],
+ [
+ "000000000000672ec8538e66c5e7880d89d47166238043bd05cfb2b6975202d1",
+ 0
+ ],
+ [
+ "0000000001b26f4ec0022ba8732d14e031adf2529f6ace8b2c4c6f44500bd102",
+ 0
+ ],
+ [
+ "0000000000b667df345a9d121c978608de555b7bc028c89841dc77aaee768b88",
+ 0
+ ],
+ [
+ "00000000008b87d9107d8d72e57191e3482d0cc563988c0a505c40545c960090",
+ 0
+ ],
+ [
+ "00000000003842b24eca3193c245c6d9548ab6aa12f585375f24c571ac92a6ed",
+ 0
+ ],
+ [
+ "000000000000b40b38ed6426cdf09de75db790198c862dfab1910cd3a180c823",
+ 0
+ ],
+ [
+ "00000000000166d9973adc0bf4f130ac15f29b10a6c04cb8e25ce9e6bf07b148",
+ 0
+ ],
+ [
+ "0000000000006bb52313e489ba4ced2480a5b39764c76eee634a1d997d1ea92d",
+ 0
+ ],
+ [
+ "0000000000003e2b6d802c3854c819713661bd5437374ceea6193de52f69ee84",
+ 0
+ ],
+ [
+ "000000000be0c0b056526b6b891040109d45adfb3f34b65c05169695f64b989c",
+ 0
+ ],
+ [
+ "00000000095fea1fe858606e16d44802a2a2ea55a4cf8c20e4f489a8ef840866",
+ 0
+ ],
+ [
+ "00000000012593712d8d9c7cc07edb52cc2926dcd3cd9d5776c256d9059facad",
+ 0
+ ],
+ [
+ "000000000000c427ba2ce29f13fdafc925c19929c5512bc0e542f7d201de6c50",
+ 0
+ ],
+ [
+ "0000000002bd5cabd8b2b9c2c1122e681f6e0d19aa8ac8733fa1e89a544a726a",
+ 0
+ ],
+ [
+ "00000000007f2a119a05c0113ed25aeea6d6f42c29eabe17fd4fd0eed2218857",
+ 0
+ ],
+ [
+ "0000000000a65e7bc4ea0927f20073536ba2bb15185487eb3653ca382c2529bd",
+ 0
+ ],
+ [
+ "0000000000026e2c50b815fe655f39ddd2a5ad67363eb346030c72ee468fd861",
+ 0
+ ],
+ [
+ "0000000000cb8615b605a76852358420fec223cb1f45b58f36fef593feb775a6",
+ 0
+ ],
+ [
+ "000000000000a9d62c3c9e45990ee3e3a0afe8ba09ee76ea8508f3501490f752",
+ 0
+ ],
+ [
+ "00000000000d6d07bf649e10f2a43b6f3ffbb3d3b8d44d88dd56968bda1e4576",
+ 0
+ ],
+ [
+ "0000000000016cdf6d13beb2a804607bba4cb410019bc15a0033d428f0885dad",
+ 0
+ ],
+ [
+ "00000000000046d0925f6aa3cb1035ce6baa03c895dde5869b63770a95135f25",
+ 0
+ ],
+ [
+ "0000000000000acc63320accbe3cfa9536987b7f46e995cbc9b6edd22631587c",
+ 0
+ ],
+ [
+ "00000000000003492c43af082403eb2bd11e02087afb5c590ba8af828c6a4b08",
+ 0
+ ],
+ [
+ "0000000000e1a369418931a7c1be9dca780253897ba64322ec598ad0ff68cfef",
+ 0
+ ],
+ [
+ "0000000006b04f242391578d9aa5233c26e6a18557ef7440ea8b05d4e7554180",
+ 0
+ ],
+ [
+ "000000000c92c04b11e0408a047f4b3d78b420dfd05668f78cc077e03ec81093",
+ 0
+ ],
+ [
+ "0000000003574dd81c51433aac32302d0e30e39703522f39a254207ef5e64dd9",
+ 0
+ ],
+ [
+ "0000000000db3d7596d805ac36b95ff841bdb7f95f60ebe2422c2c6ce1b76ed5",
+ 0
+ ],
+ [
+ "00000000000bb41a19a11b12d37b2a5cf561ae02d7ff916d37f79d8adc6f2d05",
+ 0
+ ],
+ [
+ "00000000000067675fe5b48bb05cc19526474522d1ebf71a3bbe991d95156e57",
+ 0
+ ],
+ [
+ "0000000000002a8601d3b33b5341689ebaef6e99051f5e34330af0775953714b",
+ 0
+ ],
+ [
+ "000000000000cded0095c064ee88824555bf50916806d36d27a5d5975abf8c7f",
+ 0
+ ],
+ [
+ "00000000000007d4f762e90fdb571facd1c5bcfe43f501bae3294c604427cbc0",
+ 0
+ ],
+ [
+ "0000000000000837e0ed6ba79a36a447df9395d7220c177a7dc44489766383be",
+ 0
+ ],
+ [
+ "000000000000024903240e9236ffa291ae98d057f09c79792bba7c12f49ee3eb",
+ 0
+ ],
+ [
+ "0000000000000000237351eac112eff88756b58451d6a272477690b1c2e50b40",
+ 0
+ ],
+ [
+ "0000000000000030bb4840680a28b8fa5d6fdec773404f12751b9311815a8dcf",
+ 0
+ ],
+ [
+ "00000000069d1ee42ce15859a6c76d7b3e5c4c7b76caa80b26933758b0e55f9b",
+ 0
+ ],
+ [
+ "0000000000006369a1181c0496b55cbcf0ed789c7e851b4bc7311246dee03229",
+ 0
+ ],
+ [
+ "0000000003fc3d65eae8617cdc1efc6f903bb17fdd30e6a0556fc785e33b0741",
+ 0
+ ],
+ [
+ "000000000053dc70c6af06084c6ee584e753ae6bfd18e4f748226ec8e7d8f146",
+ 0
+ ],
+ [
+ "000000000165d59df8044f95ae2ffdea9a6d3e218e5543a4ac442d90fcd1116c",
+ 0
+ ],
+ [
+ "000000000015c229ad9ccf607263dec26e90ba17b011c993a31b521047295b98",
+ 0
+ ],
+ [
+ "000000000009b5115bb2cf88e82053430e3bda4bf7a5776100b647d11739effc",
+ 0
+ ],
+ [
+ "000000000001e854436057811dd868b4be38acbfd420d90abf59e88cca893921",
+ 0
+ ],
+ [
+ "000000000000e89f85eedc448bf69d39ded3ec12829776e4512706983d678faa",
+ 0
+ ],
+ [
+ "00000000000003aeb06e89a38668d5b16d0dce97815493cc4a964f3a4c4cb208",
+ 0
+ ],
+ [
+ "000000000000079d25e51546fdac3e854af63829cf1956ca82477fbe6e373bcd",
+ 0
+ ],
+ [
+ "00000000000001a4644c28121e5b90a9dc5f574ee7a6b1531ab2a2a73bb027e0",
+ 0
+ ],
+ [
+ "000000000000010d3b4c4e22444c3bbcbe32d95462da935e94df0600c36d5d66",
+ 0
+ ],
+ [
+ "00000000000000223ce8299ac8293241676dde2a26dc673ce3aaa0d603300226",
+ 0
+ ],
+ [
+ "000000000ece8881ee79a62cd6a94669b5529ebda8ea35cb8a5b16f22157bff4",
+ 0
+ ],
+ [
+ "000000000051d022d139ac89c2b6adda09b91ae70bd9dcadd963cdef1ba3b69a",
+ 0
+ ],
+ [
+ "000000000339ded080a76b132a9ce123ed47b91b849809887179947af7013ec9",
+ 0
+ ],
+ [
+ "00000000002f914fdbdf00942b25df72be09c52a35fe5b718da39483543ddafb",
+ 0
+ ],
+ [
+ "00000000016b02a598d4e3e3b3cd7a31660f5cc96c68286b5e228c79ed4c5d23",
+ 0
+ ],
+ [
+ "000000000126e5cd7cef8f07840a563c23c28b60d043cedf8b4cd9ec0c72a648",
+ 0
+ ],
+ [
+ "000000000b0f3b81a581d5407f719ad5eae9eb3f9294403ad85e5f1555af22dd",
+ 0
+ ],
+ [
+ "000000000d3061ac9ed91baebf222a531a5473e7d8f979e1bee457e3e1cf6dc6",
+ 0
+ ],
+ [
+ "0000000000000f1528cfa6f357f44f677bfd4c92f77e863e1945a86da556f20c",
+ 0
+ ],
+ [
+ "0000000003b0f3fc912d574cc0bb312cb35859cd829c54a9db605cbb9bd52675",
+ 0
+ ],
+ [
+ "0000000000035d15430eeea3107cf09fbe0bb6ad2a7b21553b7c3f578d2c2b92",
+ 0
+ ],
+ [
+ "0000000000005cb1be53a0827995ac577379392c904740854d6f48c2b6c38936",
+ 0
+ ],
+ [
+ "00000000000026834560e7c8db380e9c26ecd5f8d6a7c1baae85385f7ba79f03",
+ 0
+ ],
+ [
+ "000000000001d0bec9f90d83e118c9e6685eda9c57b2337f4b149f2174138085",
+ 0
+ ],
+ [
+ "00000000000003b8d4d3278ae3147815537a87f9265ec6653798f30e45be92ce",
+ 0
+ ],
+ [
+ "000000000000038c72c5f71a607f5ed676253259f02b6022603b75f51a02d622",
+ 0
+ ],
+ [
+ "0000000000000d3dc9970c1feef03569f332a1d7ecbac5b980f382e49e5c6218",
+ 0
+ ],
+ [
+ "0000000001ffe2564d69c58d1440f82873b47bcf513c85df334b4f1ffa537f0b",
+ 0
+ ],
+ [
+ "0000000000886705178f570b099f8b2841312d41e819884d32eb76a2d928841f",
+ 0
+ ],
+ [
+ "0000000003cee4fba7e8d113973432642840f3a914a79164694cd752e06bc4fa",
+ 0
+ ],
+ [
+ "0000000002f1db982508e0bc313b7268f000587bfd1b247e28e553ffbd5ff58f",
+ 0
+ ],
+ [
+ "000000000026008cf3dff1367884185aa740203ea5149785fbdbfe40d4ed2851",
+ 0
+ ],
+ [
+ "000000000014798b39735397575ae0f4f389907d296fd63c9fb7029b0f7d7891",
+ 0
+ ],
+ [
+ "0000000000088f1729049693bfe1d19d256e2951970df4bf0b102f716899b894",
+ 0
+ ],
+ [
+ "000000000000f29d3ab2228ccce57b3f1b3ece81937bf780bb7fdd853ce2753f",
+ 0
+ ],
+ [
+ "000000000b021a42f081054616a39f815bb183eefff8cc0f852a28ab8a531103",
+ 0
+ ],
+ [
+ "00000000023bf7dd7ed3005de65b537c2c8876f2fbba8848132397f7c2e19b8a",
+ 0
+ ],
+ [
+ "000000000235d39c23216dfa2f37e027e5cfaec163e0f932bdb2f16cfcd6f59a",
+ 0
+ ],
+ [
+ "0000000000021dc9864764ff14afdee090b2e338f414d4e51b652b8102c00f21",
+ 0
+ ],
+ [
+ "0000000000000a15856916bee7d7605138b2baabd2e30c124b41b1061196a5ca",
+ 0
+ ],
+ [
+ "00000000000017d756a7caa1493256118efe69d7505f1add42ab9f66dca8e8bf",
+ 0
+ ],
+ [
+ "00000000000007e7c316d7143d8c4ecd91aa79405907215b87635f1aeb1b54c0",
+ 0
+ ],
+ [
+ "00000000000010b865a46467e275a653fbe7db33a3295f9586c969f7af4d91cb",
+ 0
+ ],
+ [
+ "000000000000eb5a46ee76387df02b00449911bde789a09ad2817e5f8f768771",
+ 0
+ ],
+ [
+ "0000000000001c8fccc5f507a27f1ebe034fedf98135f291096b17de6e490d73",
+ 0
+ ],
+ [
+ "00000000000005cf2c8aca82ce281df04e64b8191f96711394a96df2a49ae7c8",
+ 0
+ ],
+ [
+ "000000000000027621b9a7ee46c8632ee23b4963a27baab60c8e2b2874ca9e34",
+ 0
+ ],
+ [
+ "000000000e9f58dc29cc60e6afae3b3b6603c69f888e2b88569103926dbfac77",
+ 0
+ ],
+ [
+ "00000000000012f42df0d44cd1e5bfa062f3bb2f0760cc581d659cbd07c1bd96",
+ 0
+ ],
+ [
+ "000000000e2fbad37dc42819b05e83fe3d302c87265c46d82f62eb7dff3efd7f",
+ 0
+ ],
+ [
+ "00000000008c95ceb4b1b636e726c3e7cdf80c74dafab1c3d13cb130cee40664",
+ 0
+ ],
+ [
+ "00000000000277a72404c15eeca384bbe32fd4b546e5de1d0d39ed6a5a9a3519",
+ 0
+ ],
+ [
+ "00000000000dffbab9cc1c0744c8585aec07ba68ab4df86628e079cffadc4ff4",
+ 0
+ ],
+ [
+ "00000000000cea01bccef6472e11cf5080901900592072b3c7dc109183ec60f4",
+ 0
+ ],
+ [
+ "0000000000010e6f1b85517945fc164c74b6e60d85c9948484f9047caee44de4",
+ 0
+ ],
+ [
+ "000000000000386c87e9fa493f75937b7654721e8e411e8899b18e49e79c4f8f",
+ 0
+ ],
+ [
+ "00000000000000ec39027769af5367e61b3589d7f665296f5a01aea47a367837",
+ 0
+ ],
+ [
+ "000000000000021fef1e6512646e72fee67cb9678d7a3d3208a108f0d7a11c22",
+ 0
+ ],
+ [
+ "00000000000001b04b9a0c761f676bfeb77f47f7270799abd749afb210335acc",
+ 0
+ ],
+ [
+ "000000000000003e289ce157208586cb8a0ecfc45984c217ce6ae38b28101396",
+ 0
+ ],
+ [
+ "0000000000face4d3828727f7b30d3d7c7ba25e80f39076942d64f2b8266524f",
+ 0
+ ],
+ [
+ "000000000244a130e6c88855ed65f6e08cf1e24dd05a4562afa9e0aeac869dde",
+ 0
+ ],
+ [
+ "000000000104f9420a1ceb288ce3da17e22d3bbc4a427727a44438c4df52afbf",
+ 0
+ ],
+ [
+ "0000000002e43dff8a2776df4b712344e210bfbf92e06d90661c59576488cb04",
+ 0
+ ],
+ [
+ "000000000083c35652af9b1bab1e9e6cf7ee37ce9912cddb887ce29787aa115c",
+ 0
+ ],
+ [
+ "00000000000ffb2fca7f2c1713f8737863bbe8d7ca9bcec97d4ae783b782a1ba",
+ 0
+ ],
+ [
+ "000000000001416d9ae0fef049dff1ecfe2cc84573a80a8414254cfc07d9b539",
+ 0
+ ],
+ [
+ "0000000000006988c41ed46e245865a8ff5f8837cd31443cb2ba89c75d90b8e6",
+ 0
+ ],
+ [
+ "0000000000009d35aa98fa14329c525c8b5065beeab1a5e902611fefbe3f37f6",
+ 0
+ ],
+ [
+ "00000000000031fcc3682ae1e909d1d3b4efd1e58cafbe6eef8d3ae5ad4c7665",
+ 0
+ ],
+ [
+ "0000000000071a3ae9a9912d030e3c724dd325ddfdb0349a0e0babd1a20f5e5f",
+ 0
+ ],
+ [
+ "000000000fcb06fa14d45c00dbb937cb5e456d37862814a97d79527e44ef75a9",
+ 0
+ ],
+ [
+ "000000000000586af55ff52933e66d8fa10a4b87b0a85a369cb1679f216361d3",
+ 0
+ ],
+ [
+ "000000000349ccaf247304093a4bc06d2699b1c08600375ac4532beb386bf056",
+ 0
+ ],
+ [
+ "0000000000001d3c3190d3ffa359605719bc0007d750667b693ecccdf2ae4a53",
+ 0
+ ],
+ [
+ "00000000000047a5bd3d3a42273f93dbf0dd06058bf90e256c9ed63e911bbcc8",
+ 0
+ ],
+ [
+ "0000000000005910b434e32db0053bb9eac75e1aa1f707cf6e04e1513be209b5",
+ 0
+ ],
+ [
+ "00000000000017c0df4e9859b84a83e36bd23377792a7ab218e19bc1712fb0d7",
+ 0
+ ],
+ [
+ "00000000000022140a4f1b51dd23bb5e3a33611941628a88a38441457dcf6b70",
+ 0
+ ],
+ [
+ "0000000008700121530a0b7cecb70331bff9678570e9791a3ddd1a4f3d7bb4ea",
+ 0
+ ],
+ [
+ "00000000000005d11acd598fe7a7382712449e4dfc942469d762d87c7648c7de",
+ 0
+ ],
+ [
+ "0000000000000d245ef989d5ae64249984177dd10e1c0b2f04077276d89a0b09",
+ 0
+ ],
+ [
+ "0000000000003b8f9bade81533ef7ec7133274c55d8e29f6db4bb6545c2f97d2",
+ 0
+ ],
+ [
+ "0000000000a0489089b019a7fae1ca64ec8ef179b352e84561ac0a055675dbca",
+ 0
+ ],
+ [
+ "00000000062041b268ffc19bba83c5db5fbbec734c4c2f969d0f02cb33dd22ae",
+ 0
+ ],
+ [
+ "0000000003ffa51fff511505a918462d890a0d8e9b3038b43e01ced914ee023c",
+ 0
+ ],
+ [
+ "00000000062afff83211b48e13f909c97c8a6cbb0d702df73ac553a7871c2e57",
+ 0
+ ],
+ [
+ "000000000280223436db85632d21bb9673ea9ad270260b46b77fc6d8c2b23e6d",
+ 0
+ ],
+ [
+ "000000000000097ffea9886e82d5fed96fcb91c1b3c337b445aaaa47966cd15e",
+ 0
+ ],
+ [
+ "0000000000005f791780e489a7dbca3f372bb36ad7bad1f1d4d8584f7fba980e",
+ 0
+ ],
+ [
+ "0000000000002b8b3a8285b208bc7cba07e08f42c9705367cb5d2cc99604c346",
+ 0
+ ],
+ [
+ "00000000000002cd2f944504f4aa6c2e20dea005ca70568b18607a91c590ef11",
+ 0
+ ],
+ [
+ "0000000000003eacdfd49b651e0871ecbac1edea9e8c07014c19f2b888d28b08",
+ 0
+ ],
+ [
+ "000000000000274849103a544fe5e64a0d97ff81f0cb4fcbea269ffe01babdea",
+ 0
+ ],
+ [
+ "0000000000000279b536695aeb598fd6c01adc9f19e5b5bfb9de513a477adef0",
+ 0
+ ],
+ [
+ "0000000000082feae81a5123d7562cacd2b60476acdc27687b57cb0cb023bbac",
+ 0
+ ],
+ [
+ "000000000000db4029f289ef085ce1e55939b3225ca0e3297d0a66abd9d54c39",
+ 0
+ ],
+ [
+ "0000000002583614b4cf8c846f81f4452602f5a3663fb684a7f8d2382f83e9b3",
+ 0
+ ],
+ [
+ "0000000000d62998c90ece4773c2251143d3b16891fff7066a3fe7fe42fd6978",
+ 0
+ ],
+ [
+ "0000000000ce441f798e9188f58d9075e7a6b54010a69b8320d106584581c929",
+ 0
+ ],
+ [
+ "000000000063c5f940edd14e3d91bcdb145ed07d983d72dfe0776474fd5daf93",
+ 0
+ ],
+ [
+ "00000000002fcd1ab04af227ead9b7db9c87758953c4dacbff4b6428f6d0c6d2",
+ 0
+ ],
+ [
+ "00000000000bd2a500f81c5500a50af4c59b04b6c73dac9bbee911ac8b5e688c",
+ 0
+ ],
+ [
+ "00000000000b362c449fea49d85cdbecf81ca83e55b71476e06da440ec60dc81",
+ 0
+ ],
+ [
+ "0000000000036d40982a6d48c72de13c448f6796c55c52fc19d58b3372ec253b",
+ 0
+ ],
+ [
+ "0000000000002f469ca442dcc594acaa75b0c4cef2f4bdd4c5460ade34f956c3",
+ 0
+ ],
+ [
+ "0000000000000225af0f1a97bb54a20992fddef86ef259a52629abaa28b45e1f",
+ 0
+ ],
+ [
+ "0000000000000be9a2718132ed7891e27316eac5fca42dc196bcaa0e317e80bb",
+ 0
+ ],
+ [
+ "000000000000013c85749bdbe405cb0dc0bdd4e361281ba7a198ce820ab0405e",
+ 0
+ ],
+ [
+ "00000000000000f7939565cafaca6f31c9f61f8321ca0fa69211594cb96e7117",
+ 0
+ ],
+ [
+ "00000000007d22b331772cf0dd919000ff5ec754e1b9254b90fea7d694501a85",
+ 0
+ ],
+ [
+ "0000000000c41b7447a1a24841abdb7c3f2b3d68bf7707966b3124ff594c1da2",
+ 0
+ ],
+ [
+ "00000000007976d00aac373b41e6f3e574ff607beba8551dcb7fe6e0a6a5990c",
+ 0
+ ],
+ [
+ "0000000000014c8c2b476c525b656908b1ad6cc5f7593f63147b882ca38c5870",
+ 0
+ ],
+ [
+ "0000000000bd0a837703bb57aafa84dacb49a264b5335c42442f0e83fcac2841",
+ 0
+ ],
+ [
+ "00000000003ea8019a2641d1778bb416f6d9e0ab9b068323654bc55cf7b2d304",
+ 0
+ ],
+ [
+ "00000000000d0a442a0865a501b427e6425151f5923606bed29e94a72f35a07e",
+ 0
+ ],
+ [
+ "0000000000030dd08b942ffb56e9a8eda95a7e6f28a03b9d1f488a29c7fc14cb",
+ 0
+ ],
+ [
+ "000000000629ebd7d6c56c34e858ac6ee105aec8d8db05db5f4b1e7bfec8033a",
+ 0
+ ],
+ [
+ "000000000038d3dc0b68fcd3275c38b3225bb99bca973a513afb2fe505362d6b",
+ 0
+ ],
+ [
+ "000000000072b439db6364b048becc5254682c31214f44522566290171447c9b",
+ 0
+ ],
+ [
+ "0000000000eb455b495292a97133957356fb0e145a593c9920e99bbc1fcbb91d",
+ 0
+ ],
+ [
+ "0000000000f3c0c53d7b41b7366a44090d09af626aab3f207f94e0506eb2abd0",
+ 0
+ ],
+ [
+ "000000000014463bee2df062f3a0d84752b55b96c6cc16595899fb2452f23960",
+ 0
+ ],
+ [
+ "0000000000089435da00b1ff30d48724160cb59877b19bcb3f449d9c279824b9",
+ 0
+ ],
+ [
+ "00000000000033ddc62f3518f87c4acdf8c0962e5e0753c86e023537bc73906d",
+ 0
+ ],
+ [
+ "000000000000cfaf02a175dc7a7efdb4ee10eb83d3a04678f0adb5c300b9acfe",
+ 0
+ ],
+ [
+ "00000000000014a5fd14c81bd67ad64fb81d9672b8d03877a9d3824c97a715c6",
+ 0
+ ],
+ [
+ "0000000000000b79c643a371a491bb6a27726102f8797c7d08ddf87ed6b334af",
+ 0
+ ],
+ [
+ "00000000000000499f0d78b9f43fe1f8d4a981828049a2546059d1392f2babc7",
+ 0
+ ],
+ [
+ "000000000000006494d55236e0ced5ddb0a42dade401b3d77d456e447f666ddc",
+ 0
+ ],
+ [
+ "0000000000000003ad1c7710bc02bcdd76fe6fca54869899b9048091cccb93b0",
+ 0
+ ],
+ [
+ "00000000089b0706b3a6b5808ddafb57be5f77e51009afbc81aba91ead1af1c8",
+ 0
+ ],
+ [
+ "0000000000038dffc62eb25ad2301fc45ccc82a9ca0f3a730d7d25f4cc7695f9",
+ 0
+ ],
+ [
+ "0000000000007cdc062f569a2227502e75afa99c7ff7c9fd3e043ee61f5e2899",
+ 0
+ ],
+ [
+ "00000000000050f82f9fe703482a89ba92aad0be11aa686fcafba4c7ca0c4b01",
+ 0
+ ],
+ [
+ "0000000000005d08ba1d46fef300bd60382be096d46c036622a9c52466fba6fc",
+ 0
+ ],
+ [
+ "0000000000001c124114694c9c3bbce398d3470bbd8d0795152541a5de1e8189",
+ 0
+ ],
+ [
+ "00000000000047ae2c50d65efcc15af2014d5b0049d244d8dc78b482151f0e62",
+ 0
+ ],
+ [
+ "00000000000064a0f4a34ab095a76b9c394069f55bd6bd2b783b5de028b9f87c",
+ 0
+ ],
+ [
+ "000000000000f33234204685893b980d24bac914bc8b1978c69d8580fb63f542",
+ 0
+ ],
+ [
+ "0000000000003aac07cd725764eadfa1d887b0d4508858f31e46e711aa801e8a",
+ 0
+ ],
+ [
+ "00000000000005c12a0970101bbe661f57f63686c48825136bd298b951e86e2a",
+ 0
+ ],
+ [
+ "00000000000003c4ea51aa96323c5708289580f8e7b6e89d0d3bdc08d0db94ff",
+ 0
+ ],
+ [
+ "000000000000016b84c8036b3b3fcd2f31928c949d4d35b4400ae80ba23b247a",
+ 0
+ ],
+ [
+ "00000000031da21f07983eac7205006b839fad1e14f339c065c3ba0789f47a8a",
+ 0
+ ],
+ [
+ "00000000010a23cc7ddef199edbe37e42acd2461bc9848710e467739423ce2dc",
+ 0
+ ],
+ [
+ "0000000001ce777eefe9a1f1aee20f33629aba343e5782b3f4ec02e41e282a85",
+ 0
+ ],
+ [
+ "00000000017972967a46f08c4e79bc29283566c539ff8590247819223cf3f1d0",
+ 0
+ ],
+ [
+ "000000000000fdb8834f2e67f6bbb4409935c299d46392c8db426359deeffc27",
+ 0
+ ],
+ [
+ "00000000000023f8eba6eb6d59206adf050dc76b2dbf41b66421df4060c7007b",
+ 0
+ ],
+ [
+ "000000000000327a31e6e0bf7261edf25cba59c81b895c9d1568cfb9e12371aa",
+ 0
+ ],
+ [
+ "00000000000033faab4075e74d8b908cc5b586bee4b99464163798ebde37925c",
+ 0
+ ],
+ [
+ "00000000000062006eae8582aa0d3b6877216793ddd70a1028d65e0cb2c475d3",
+ 0
+ ],
+ [
+ "0000000009cde9229b24519ef150d00e78249587cd2be3a293b2ccf807f7b1a8",
+ 0
+ ],
+ [
+ "000000000fcbf2f23f11d11aec4df1375ee8ad149fe2c32b52615fe01b327b15",
+ 0
+ ],
+ [
+ "000000000a26326c9fe65505e8dbb95f2d10bc5915912be2cf3a3c66f4617c2b",
+ 0
+ ],
+ [
+ "00000000005e1f0b9aad53b290a662096d88bb719b451007ea9e23ef990a42b7",
+ 0
+ ],
+ [
+ "00000000000061bdb1e6cba904842454e0eaa79cfef1d2de098cdf62fae9f67e",
+ 0
+ ],
+ [
+ "000000000000238380880d6af14a72d9f5cfae0a6032eaa0a4ef1291161a83d3",
+ 0
+ ],
+ [
+ "00000000000007f7088a701efba448b2a3465fb0a03ef2a83215bf5cd9138fdb",
+ 0
+ ],
+ [
+ "0000000008b6f1711e2cad14256e563ca3e7927957c3d6779ff381f5c5cd3220",
+ 0
+ ],
+ [
+ "00000000081b05a0ae2b231dc43bddea3c258f70a3ca20500773e29a7b5d75ad",
+ 0
+ ],
+ [
+ "0000000000000003a9eb8f76429fa078e2e20af75ce5b75da0937ed06cea43a7",
+ 0
+ ],
+ [
+ "000000000bcaea5fa7f75beb66951d22d9fb7330bad2b84b0e2039a61f9d5a4c",
+ 0
+ ],
+ [
+ "0000000000000558d535b20ba51c6212e683e0414f8430ce4a9a5aada7c7ac4a",
+ 0
+ ],
+ [
+ "000000000000379c6eeca100e6f171c71df430611b187043c2b422449560dce1",
+ 0
+ ],
+ [
+ "0000000005c7361afbd59825f0243a94c47f62dfc2856f490ce11df9f449b787",
+ 0
+ ],
+ [
+ "00000000076248cbffb87ade5efc8be5dfe3329a2ce1d2a488fdb23d48efb75a",
+ 0
+ ],
+ [
+ "000000000e283546c050293ac632a61036c18dee9bb810835b661fdfb08f6635",
+ 0
+ ],
+ [
+ "000000000321148782953d6b0b23eebf7b7feda7df39204ae52e28d224752b65",
+ 0
+ ],
+ [
+ "000000000077be32cafd14823697979dfcb4843cc11d8d44f2afff9bad1b6d0f",
+ 0
+ ],
+ [
+ "000000000000567176a554c5a19cab87b7f0a86d98402a09d6613c0f389ca567",
+ 0
+ ],
+ [
+ "0000000000000b1996700d0427eb92eb52986a3f4639c9e11b8ce37f2dda66aa",
+ 0
+ ],
+ [
+ "0000000000001b3e599b0d31519e75ec45aefd549df5b6028aac6d4d69cc829e",
+ 0
+ ],
+ [
+ "00000000000046df035e6fe190b203dcb62c9a1a7cdb08389c79d3987c6dd8d7",
+ 0
+ ],
+ [
+ "00000000000003e9e7a602aaebb90b73d5004844d5f6f24925b14188e34afc2e",
+ 0
+ ],
+ [
+ "0000000000000e6263183a20de2b098d31b20c4b857f6d11c523755f5c287fa2",
+ 0
+ ],
+ [
+ "00000000000002cb548427aef898e94253e0a0ec8ee53c000813551229eeaf6c",
+ 0
+ ],
+ [
+ "000000000000004ccbf0d92b3aa2285be79b17fbe0fe261ee1c682cd1dd927e7",
+ 0
+ ],
+ [
+ "00000000000000314c5668fca293d377ad4715bd2b5379318ae2e81fadcb1502",
+ 0
+ ],
+ [
+ "000000000073f9d5a07e50d1701103c1c12506c54af4e2d96736b267e3b8b3ae",
+ 0
+ ],
+ [
+ "0000000000ed2345b418284bb0378152d0bc8f360844955f1bdc1797f557a621",
+ 0
+ ],
+ [
+ "0000000000001da2c63c314956dd2c633f40f8cfefc034479bd60089fad02b88",
+ 0
+ ],
+ [
+ "00000000091985d3f909245582e26d7836edecc5eb47515e590a5d0b08940de9",
+ 0
+ ],
+ [
+ "0000000006a654ca1a6bb988584b478197fd0eddbfa9edebae55e2f419712212",
+ 0
+ ],
+ [
+ "000000000c36097cf9734e7a3fde243870fc9122121e34a9a22fd235c0ce3bdd",
+ 0
+ ],
+ [
+ "0000000000001ed546608547145905444cf18e7d27d537bc3253b295ebf6164c",
+ 0
+ ],
+ [
+ "00000000011c08a153b85244c93e601cfd715845ef7bfb8678d52d72eeb33fe2",
+ 0
+ ],
+ [
+ "00000000003950ad79520b26976725c64556e65d21c7b5029d467decc056d30f",
+ 0
+ ],
+ [
+ "0000000000003ea7f0a49bb8e6be6c09de704de3a8d329e018d5f34cd88433d7",
+ 0
+ ],
+ [
+ "000000000002048815f8a57a4688e0ff2ec7b0c6dfd5a855082097e16c1b4f0a",
+ 0
+ ],
+ [
+ "00000000000021f8ee9909f7102cab8462d8d0ac43a451a864bc792dba0541c5",
+ 0
+ ],
+ [
+ "0000000004515238ac80d8e7ad1403ef3c97a9651493c9de89a4d6ce06bfe858",
+ 0
+ ],
+ [
+ "000000000ed4662b59df94409e92509ea67f0753396010c136f5652cca559592",
+ 0
+ ],
+ [
+ "000000000faacffdbbc3459c6490f7d2d058c40913ec9b1efe2ce0765af4c993",
+ 0
+ ],
+ [
+ "0000000000001adb00aa71e333150e5ed06bb90e92cb871d74e7fad5c076071a",
+ 0
+ ],
+ [
+ "0000000000b3091c11666e2e25f66ceb5cc89c43fe4cad628ea96bc5688ed556",
+ 0
+ ],
+ [
+ "0000000000001796f7dca239170f18edea5d2eba4ad7ace947c4986c9bf5ca06",
+ 0
+ ],
+ [
+ "0000000003504db8c65ba8631a483894da912c31ab48805d9d75a69456e6cf93",
+ 0
+ ],
+ [
+ "0000000003f643be16ab8974aa0dc202a16c77a333918b715dc068859ed60ff1",
+ 0
+ ],
+ [
+ "000000000ff7209cd7acc0e8be21437ec4afce623a80a44c05a655aa3423eae7",
+ 0
+ ],
+ [
+ "0000000003d1fa3d1c9a8082d1c12da56b3f537b5cab23295dc03187923dc33c",
+ 0
+ ],
+ [
+ "0000000000392fc59c41a98a986cd160c1ca491fbe3d638bf9acfc9f565344c3",
+ 0
+ ],
+ [
+ "00000000000077d2ef23f91f9eaec66263551275f1cd58e42f212cbef1f1514c",
+ 0
+ ],
+ [
+ "00000000005360c2362e89b2001957e8563f2f29bfc5c67453643f972603d457",
+ 0
+ ],
+ [
+ "000000000ec466e23f09fad26ed11d1611d948e82327c21392bf1ecc761d9dfb",
+ 0
+ ],
+ [
+ "0000000000bd6cf941aa95726e74c57689dbacda79c61f61bd099a3da98ca08c",
+ 0
+ ],
+ [
+ "00000000006dcdae735bd55c3a6472c3d8b62d6ada8734b396ef768dc160edcc",
+ 0
+ ],
+ [
+ "0000000000e8d3a687d796d63c201570bf58e3a2bfc56ff5b2b98dba0590e8c9",
+ 0
+ ],
+ [
+ "000000000077c5459d889081076e7bc3596d1afff69401e3ba9a747a41527075",
+ 0
+ ],
+ [
+ "0000000000288bc04badcaeb4dad4c95188aa2af23b9713ab516acb7a2ba08a1",
+ 0
+ ],
+ [
+ "00000000000018f0c4517333aad28a6bd17bf6599b23f30e736f8b81453c91b5",
+ 0
+ ],
+ [
+ "0000000000002bf35f1ddf7f173451b8fdd99b09ca7e7acdaacd741a2ac4ee78",
+ 0
+ ],
+ [
+ "0000000000001b73317bb3e44d0e0c478b607ffca894cc64c4cb7aa46372e395",
+ 0
+ ],
+ [
+ "00000000000036f9c27a5fb4a1e3408c0b5af23433a8fb914d1ecd980a78cd8a",
+ 0
+ ],
+ [
+ "0000000000000db0c1c9f82751891801d681a1c45fb1c41595c81443f0a1e950",
+ 0
+ ],
+ [
+ "00000000004a8225a3861f2d8fd6a2c4f2c97f9580269a3d47c4fd36e1f835e1",
+ 0
+ ],
+ [
+ "00000000005bda823369e5f94c49834cd98af02630cf33bf63a5126d81aeedad",
+ 0
+ ],
+ [
+ "00000000096bb045d967239d012773b9c74995439fa29169586283b9771ffe11",
+ 0
+ ],
+ [
+ "000000000fbb4587b5a097ed17d5a3f4611a55c5697df344759e90b2074df8be",
+ 0
+ ],
+ [
+ "000000000a34e610a3b5a3cea87bf4dc50cf5ef4f4256fbbbf713850a1931750",
+ 0
+ ],
+ [
+ "000000000c7115c2136182db49423e85b3f03b72df0095d8b48d41a6e7a5c4c9",
+ 0
+ ],
+ [
+ "000000000dc75a70535dd6998830eab1201680703badef3cc0c506845b05b411",
+ 0
+ ],
+ [
+ "0000000006209e2c1585e816bf6ff79aaa52d709e3e15d8c79b907b4b9af482c",
+ 0
+ ],
+ [
+ "000000000df4fa6c4fb5d4277ca22c2b3432aa3d3d4d34c93fae21c5c6e18f13",
+ 0
+ ],
+ [
+ "000000000cc1645c4f9dcba4ab347a632d3c3d00b0ae56f72f9a8791c2589ca7",
+ 0
+ ],
+ [
+ "00000000035de13d1b950ba799262cc730f65cda1ab33c86cfed67c14ab59b12",
+ 0
+ ],
+ [
+ "0000000000003c0666573e408040e408df8265c1b24d4ce53cf6efa00c289d51",
+ 0
+ ],
+ [
+ "00000000002a301f000f086912f45dfa1a6608d6d2a14f1eceb23f852cae290e",
+ 0
+ ],
+ [
+ "00000000000774b67925ebdf31b1f455db3ad534fd9aec0e43bb79925c77b221",
+ 0
+ ],
+ [
+ "0000000000001b8f1f932360a5936ec6f007ea22ccea5579cc8667f7616977cb",
+ 0
+ ],
+ [
+ "00000000000035a0698b037925a892e4766d659ff3f7979209552a9ca646203c",
+ 0
+ ],
+ [
+ "00000000000001437edac6d3e1bb669d907b93ad506f8c096a99b8a0f8cca1b1",
+ 0
+ ],
+ [
+ "0000000000000e1daa4bf888d63a83097c13cc37318174868d6cf47fd5951b38",
+ 0
+ ],
+ [
+ "0000000000000067094d226dce682adf9b13225891e3c4bb800cf7087bd20804",
+ 0
+ ],
+ [
+ "000000000888d73db90a3cfc554070f42ab6e66d7ed603ac31dcf75f56ea2496",
+ 0
+ ],
+ [
+ "0000000000dc11380abc06c3c9919c140ff2f79ee854457b7ec6219111b24a07",
+ 0
+ ],
+ [
+ "0000000000002519e28eaeb2a1c9f7dbab73f220408b2cadffc4edf2ba252e4c",
+ 0
+ ],
+ [
+ "00000000005aaf523f208621a67f1d79bbc49a55d23fc174fd51d5b20ab046b2",
+ 0
+ ],
+ [
+ "00000000000017eb2b540a7ad338edb2291d3a718624670f0e1eee89e78bab4a",
+ 0
+ ],
+ [
+ "0000000000000d3119520427d81eecb014cc8911390560ccfaa21932801a5e91",
+ 0
+ ],
+ [
+ "00000000000d6e620685d7f868d6164e548c0296b8e32263cd557cb3a38826a1",
+ 0
+ ],
+ [
+ "00000000060ba49b218cee56dd5fa41d66c45476fd9f3da6ec338df98f8f3af8",
+ 0
+ ],
+ [
+ "0000000000180f7317cc1d110cb221b6767d908b0c6582c859e78c9f04dfb800",
+ 0
+ ],
+ [
+ "00000000072fea2d890c6368162518bb9ad345adae68b106cf15cb20ff241350",
+ 0
+ ],
+ [
+ "000000000026fe3d8e277d69708a69cef90a7f2a761796602ce9112644518dae",
+ 0
+ ],
+ [
+ "000000000045c77f25f1807d143280fa8464ac6df9c7a4b56bc4e34a9416b9ce",
+ 0
+ ],
+ [
+ "00000000000542f8da30137f2283a54e81f586985f924561e558d097bf972a9e",
+ 0
+ ],
+ [
+ "0000000000001c3c44d10ea8bb958ca80dbfa5733ba66b8bed179b05d6106d5a",
+ 0
+ ],
+ [
+ "000000000001c1c53fede30f9b35b3cc05605514e2ab909f4bcff86bde884f7c",
+ 0
+ ],
+ [
+ "00000000000009b60a8790fe0524faa3c2399a3791fdcc217d21d02cd935c287",
+ 0
+ ],
+ [
+ "0000000000000d86b1ab286a7d2b41db146575311106f58cf3117d831ca81ddd",
+ 0
+ ],
+ [
+ "00000000000002b444cde3ce10aff0f4557d918ed7f7d3d33e217e2c4b4d6ef8",
+ 0
+ ],
+ [
+ "000000000000006c1bcb0b8650557494a2dcbaebb6372dd6a893f372da0cad56",
+ 0
+ ],
+ [
+ "0000000000000032288278d76032074a25b0f45b2b470a99b08641a0b789b085",
+ 0
+ ],
+ [
+ "0000000000412b6c96e0c0382e0d8d154f567562dbdd6d515ed3b008fe4c8d7b",
+ 0
+ ],
+ [
+ "00000000032d4d5006d3753adb0a9c6e0a0a320adb6de9a14fe89c184977fedc",
+ 0
+ ],
+ [
+ "000000000b8aa72c0093b8dbf56ea257c2aa752467ce63be33b4009429932b3b",
+ 0
+ ],
+ [
+ "00000000000036cb2a9820fe0fa9cd6b28a0226ff4d7c0e06f3f9de336746dcd",
+ 0
+ ],
+ [
+ "0000000001010dfc436ee032a16952fd8ad1342752e7ff2b0244d150dc6118a4",
+ 0
+ ],
+ [
+ "000000000023567623e752cfe32267a6ed9fbbff4f167d88f1e3f9c3528cfb54",
+ 0
+ ],
+ [
+ "0000000000001ad1b428da9f6e81ee3ad109b4c31532842f59dcd03fa1d830fc",
+ 0
+ ],
+ [
+ "000000000000555f57a00b2f4ef10748595582e34ad13241838217e295eb9d46",
+ 0
+ ],
+ [
+ "000000000000bafd77624538518bee5f02e497de67285b907c7e7a1de38905a5",
+ 0
+ ],
+ [
+ "0000000000000aa8dd2bdc793b12bbf8e548fba5e8b35944631c326d4eb4b0ee",
+ 0
+ ],
+ [
+ "0000000000000579a497cd1f9e7bde38f0baf65366cc6ad2073159977fc9f332",
+ 0
+ ],
+ [
+ "0000000000000030ba581e96eee41480ed631a7c2f6d67503f316760491079ed",
+ 0
+ ],
+ [
+ "000000000000007ce65511b1c34e1445607ea53bdecf9fae9b2e0eca10a6064c",
+ 0
+ ],
+ [
+ "000000000721e295cbeeb7e1d460d55d6f48396139e3bbe5000b76256d87e936",
+ 0
+ ],
+ [
+ "0000000004db6aaa2d703469e774cdc2dfa758072c3b32bc1498494b7333ee8f",
+ 0
+ ],
+ [
+ "00000000010de49ec83b2a5ad987e222639c489a53bf7f6b1a9e74baee07dc05",
+ 0
+ ],
+ [
+ "0000000006380beeb928bed1a6b15d78f1456b775a4fc40ddbe98b7189dc000c",
+ 0
+ ],
+ [
+ "0000000001699320ce4fabdade058c18d404468378882bb8e2e3130502210956",
+ 0
+ ],
+ [
+ "000000000086702466b4b3d53a45c78acd02e6fcd4060915482f7bcb8ab18825",
+ 0
+ ],
+ [
+ "00000000001fd44a586b1f3c45d531d74a21364cbfa32922a5dff0747295c201",
+ 0
+ ],
+ [
+ "0000000004600d0aa3fae03ccfb2f52c6cda7a2f0c0377ac93f0ced5705c8083",
+ 0
+ ],
+ [
+ "000000000f3524398295e430afabf4f947faceaaa913e7a50d52bbd04fdd945c",
+ 0
+ ],
+ [
+ "000000000fa978185c0f6b6b08a5d3c920528c3044da2af5de67066adf8c20d8",
+ 0
+ ],
+ [
+ "0000000003285aa7311c3522e58954a630cd15df159f312bbf77fe30572c6680",
+ 0
+ ],
+ [
+ "0000000000000fb07884c4ff829424937d02586452ba4eb6285feaa27d68d59b",
+ 0
+ ],
+ [
+ "00000000001944317acf0e191bd6dc7e743cdec33fe09ee3c0d4b540e36a8202",
+ 0
+ ],
+ [
+ "000000000004af4f58f7974bf4434017104050fd14cfcde5473e84239c9e8507",
+ 0
+ ],
+ [
+ "0000000000002531b5caa3f5f0b58102a04c4462998dfe8790001b39823619c7",
+ 0
+ ],
+ [
+ "000000000000fd6e966ce4b55f4c8319fda3aeb7ccc7f0162879f85050454499",
+ 0
+ ],
+ [
+ "00000000000001f4b22e4bce9c874dc7f81fd81de30890bf5c0037c132f399da",
+ 0
+ ],
+ [
+ "000000000000013703a8d800d1e40ca4db69243003ce1f32eef75cecfbce34c6",
+ 0
+ ],
+ [
+ "000000000a9261a8d5f86317e4a4ad56ff96a95b3ded98f22ece31e9e7e1699c",
+ 0
+ ],
+ [
+ "00000000000ffc1562d4903732ec53396d298a488ed52b2d4099148721f47185",
+ 0
+ ],
+ [
+ "00000000000f57177045b1df6c0010e813089f7fccddf88ca9050008e043d5b1",
+ 0
+ ],
+ [
+ "00000000002f57d47c85dbe00927776e12bf3c332d7848e2668f7cc1412be086",
+ 0
+ ],
+ [
+ "0000000000e1cc3c5215f5a848f065ebb3a43d30bc4580681ccc32dd7093e82c",
+ 0
+ ],
+ [
+ "00000000001b961551a1797ace18d54443928afab8b53a9d883b4d33c7065794",
+ 0
+ ],
+ [
+ "000000000006eb5f5c91d53b92f2f237abfe4215e16aba860b9c83ff4531a363",
+ 0
+ ],
+ [
+ "0000000000028a7ed33b6625a08785a54f58c6e40c686a8868bec43ea07cf524",
+ 0
+ ],
+ [
+ "0000000000004c23f5e2291ea84114504ed53c0cdfe39fd0962ad51e3f44a71a",
+ 0
+ ],
+ [
+ "000000000000359cd2356a0ef9843a6b0b089469e20f39d86de6336167e3bb31",
+ 0
+ ],
+ [
+ "00000000000007e77bf73bf030331c461ab57c17c7b52e1789b1992a3bc6a036",
+ 0
+ ],
+ [
+ "0000000000eb8fd6531c52d17c8b78367415b12b946fc6c906d63239b85194cc",
+ 0
+ ],
+ [
+ "0000000005b862746087e30836e21d6169e88dcc891edc9965ea8d3c6a5d1ca8",
+ 0
+ ],
+ [
+ "00000000004c3948942914866eed7fdb5b21c8d6f7926e633d4ee463661f1236",
+ 0
+ ],
+ [
+ "00000000000f838dc5fe195485eefc05e57064ff5744c8ce5a40fb038d611ed3",
+ 0
+ ],
+ [
+ "000000000018fa4d5ad9133b828d1d4847017f9c28f266858380ac84aa48bf20",
+ 0
+ ],
+ [
+ "000000000034b78e1088b935f089917350ccd7183dde2e70482bbe70fb802377",
+ 0
+ ],
+ [
+ "000000000000023432a73d4cdb6a9b81e64cfb0e7465e2cdfb3df78140622485",
+ 0
+ ],
+ [
+ "0000000000000dbd6ea9ea2f6c0846f78890747eb7de4b83727a4e9f769a3bc7",
+ 0
+ ],
+ [
+ "0000000000004add97d7579bf34384dd3164cb25d4c5add6534401910de4f2c7",
+ 0
+ ],
+ [
+ "00000000000002053b7bf544342efc88c71785472fb1f4f92b592d9bba7ad26d",
+ 0
+ ],
+ [
+ "0000000000000c2e80a064b77191131706bebc1196bac9ac913aa63f44a3584f",
+ 0
+ ],
+ [
+ "000000000000008a76191fb3b5e341056c982b5a0631c81a7d23652224968841",
+ 0
+ ],
+ [
+ "00000000004fcd575e591cec98931fe9b35df1224b6ee9cda2915ef2d6a907fc",
+ 0
+ ],
+ [
+ "0000000000001d6b8d2caf598365e3f41789bde086859a9c093ef5c6ac4531f5",
+ 0
+ ],
+ [
+ "00000000000d778a5dcf7e84197ba476035f802a17562af2a6746cc46c6a8ed0",
+ 0
+ ],
+ [
+ "0000000000029a99f42369b9f1aea9bbd47390d2bacad09a40637536183ee2a5",
+ 0
+ ],
+ [
+ "000000000004e100d497e03ae45b7d039127985bb66f822f7d0b68a2919e0c1a",
+ 0
+ ],
+ [
+ "00000000000a961916fe000f2b7761f867a16f2e8a29bb6cc58dbac63c26b71b",
+ 0
+ ],
+ [
+ "000000000004e13f7bd19836f89f3a8757ce80ebc7c4ce2041dfe196966b9b76",
+ 0
+ ],
+ [
+ "000000000001d5530f7faa64ac828bac0dd66f058bdda86f320ccb4ea6b4705c",
+ 0
+ ],
+ [
+ "000000000000184818f0675a919869bce1ad0484f5a0734a8a3c6678716b35db",
+ 0
+ ],
+ [
+ "0000000000001d3cef6965a0591426ac446aec5e714b6da2f698f240a8c7b297",
+ 0
+ ],
+ [
+ "0000000000000a6251cb71b0e7c79d577ba70f502a0fc8a235188856f062148e",
+ 0
+ ],
+ [
+ "0000000001a0d7ae29d0fb16055f476831d372d3c3b9aa6ffd30fc3136df2618",
+ 0
+ ],
+ [
+ "0000000001b5c42166bf291e425ffe152401360ef771fe656a37612a6bc9c32b",
+ 0
+ ],
+ [
+ "000000000ee998ed1c31cd407a8ab2de2d119c1d38fe166f3a133e1b529162ec",
+ 0
+ ],
+ [
+ "000000000f6d809b34d3a42d9b4c93271c62e225e5a7e1fd20a9ed92af3bd988",
+ 0
+ ],
+ [
+ "0000000009a167f6d9d8cc6852aaf12be5f4b3819929a6e12a372f66296a8e8b",
+ 0
+ ],
+ [
+ "000000000345f1453151412ea96a82be32be8fb6ce6d646ee608b8bc8ae146b3",
+ 0
+ ],
+ [
+ "0000000000be58836d26e311860860cf884d9d098abc2cdd6423ba1b2b2e41d3",
+ 0
+ ],
+ [
+ "00000000000010d95c3b409b8ea4df1780d3c9f9c7ca087b24c7c0c141fc9c97",
+ 0
+ ],
+ [
+ "000000000b81334ce2ff301cfc3c94c86dd02bbf72cf3772278eff39cd71832c",
+ 0
+ ],
+ [
+ "000000000c2bf411d96473fa1c463950040d008ebedda1189a1cdb54d7bb31f2",
+ 0
+ ],
+ [
+ "000000000e7ece2a317027c09c9cc2210c177a865bf9b7c85012b9854f2bd850",
+ 0
+ ],
+ [
+ "000000002731fab33587e08e23ac94c1d373a15d7b9875766188845ee5a969eb",
+ 0
+ ],
+ [
+ "0000000008a30b71218412c43ed85a015fc4fbeb1dd2243992578b501407a0e5",
+ 0
+ ],
+ [
+ "000000000f7e4666ec661ed4ad11b4767cdfcce1208e8cba788d74a9bad05b1f",
+ 0
+ ],
+ [
+ "000000002503bfac95c7fbd3dcf3ff56b9d555c5e604f5f36d87f2e19eb0d353",
+ 0
+ ],
+ [
+ "0000000000006abbe507bdea8936ccfb72c08195aaa81dc11b1d3f550d4c54dc",
+ 0
+ ],
+ [
+ "000000000000ad055b6e8966c482af5305e07754af2d8d520d5c70bece826807",
+ 0
+ ],
+ [
+ "000000000b342baee465a347a4569cf50ab95376e59f87bdde38d9433f6428de",
+ 0
+ ],
+ [
+ "0000000008a12eeaba440680a497d1323f27c4a27342087ec4c0460b12a7805f",
+ 0
+ ],
+ [
+ "00000000086695a3bcdb7a9ad86f91f6da9da221197983bcfc7ee5a2ba69da9a",
+ 0
+ ],
+ [
+ "000000000bab17aa2bb225314742c07ccb247feef53a4cbf08d65622e7bac06c",
+ 0
+ ],
+ [
+ "0000000007f776d9cd5e718b2677c24596029fe9c8694a208b75e1170f534735",
+ 0
+ ],
+ [
+ "00000000000004ec0928faac1f3a1031b2ab5e462936419c0ae17124dabec4af",
+ 0
+ ],
+ [
+ "000000000006a2cc94305823c0a5d30a03f1340d45c36ce754cd299631bac67a",
+ 0
+ ],
+ [
+ "000000000acbd3a599053a3b03901ea2bb4cff6ca66824b064f2d901ad66bdfe",
+ 0
+ ],
+ [
+ "0000000000001148be4d8ab2acf66cf0dc68f636fa394a059cc6c50a57ac579e",
+ 0
+ ],
+ [
+ "0000000000eae9077d1dfe58b371c399d3e73f6dff18e9f7bd52fa5528734af8",
+ 0
+ ],
+ [
+ "0000000000343826ded7a6de205d98ba2b4f4f64ce05af8275e6bce684c62631",
+ 0
+ ],
+ [
+ "0000000000094454634a162499692b4b625fbfb119df402e2c42dffe54feadca",
+ 0
+ ],
+ [
+ "000000000029f9cd34b0f879107f884772c7a345a4e625cad45055f5df847629",
+ 0
+ ],
+ [
+ "0000000000067abee9818a634edc244ff42eb6b71cd6ee88d41b1791d7bac8c3",
+ 0
+ ],
+ [
+ "000000000000c4c96ca78a2607c59b175dbc83c6a386964230dd9a29d59f3789",
+ 0
+ ],
+ [
+ "000000000000715f720c3ed3e8a988499cb39f9b84f448fc7e14546cc62c44c6",
+ 0
+ ],
+ [
+ "000000000000358b18bde41a77fb5e97ee1185e1be83b832c18d0c2ae38a073a",
+ 0
+ ],
+ [
+ "0000000000000375efa661ab6166a51e19497512052940d8218b8d893fcacaa6",
+ 0
+ ],
+ [
+ "00000000000003af2ce5930d4c686f2630ad58447ef6e5da36fb6d0c5c73acb6",
+ 0
+ ],
+ [
+ "000000000000008ff35d010d7f94b841f37f2c9801291acd029e49975c6941c0",
+ 0
+ ],
+ [
+ "0000000000000011f4f1c7929b38eadfa067cf148026cf40a4cd0f87747c07b1",
+ 0
+ ],
+ [
+ "00000000027219ff91c48b554b87d0375e2231946d6eb4ecc547f4a7234f6d18",
+ 0
+ ],
+ [
+ "000000006354b8f3148131ffdec12cd0b63c76c0c2a0e037d0725d2c24511ef1",
+ 0
+ ],
+ [
+ "000000000000037c25c08ec40b55159f87e78cf00df700e2ecf9d5fe537a0e7f",
+ 0
+ ],
+ [
+ "00000000000d74e182a79f8ceda21b27f63c6622c66e75512b617e3ce8a736f2",
+ 0
+ ],
+ [
+ "0000000000cc362906f644f2a649a9f17a9e3a08e123bf08971d829cb92e448c",
+ 0
+ ],
+ [
+ "00000000001dd7a9253268e93bc0ca0cdda096cbbf14445a5a979a17607207cf",
+ 0
+ ],
+ [
+ "0000000000060fb6d03127b2d0669e1b061b730c20ead99200d5ec8f0028975f",
+ 0
+ ],
+ [
+ "00000000000008637fd1b69af2bf8b94065fa444382cac2b4745484b203e6822",
+ 0
+ ],
+ [
+ "0000000006c7f6d4c0bf9d2b313d3039a3864c9baedc9fa0f829b8c2c65690ad",
+ 0
+ ],
+ [
+ "00000000002512b588ec16f52ce7ea23ce183d5956b381a951f35373814b5382",
+ 0
+ ],
+ [
+ "00000000000c3374ff0eecba8f087b61d7e35d8e66b57d47b81e0c64c83ac526",
+ 0
+ ],
+ [
+ "00000000005e81310d1afe6822be62de5c2137334ae33ab48770023f07c59c84",
+ 0
+ ],
+ [
+ "000000000002802db6ef175423f18eaeb1a1b4a31b93ddb9508a78c37380ff1c",
+ 0
+ ],
+ [
+ "00000000000e324cc1ce853af182634f58096aaef2725d9220fd6a7ec349703a",
+ 0
+ ],
+ [
+ "000000000009a845400bba282bd1e2907e6423dcf74fb1fb6f96ebaf6abd9b84",
+ 0
+ ],
+ [
+ "000000000000d81431902f3998dc0283a54ef5652fc8df091bb65d80c996d98c",
+ 0
+ ],
+ [
+ "00000000000066bd25372ccecbe69d487fb2190d81a8e67679e484d541ff83f1",
+ 0
+ ],
+ [
+ "00000000000002a470b897fa91318a1f34ad9d7dfa1203cfa6b7d106bd8cca07",
+ 0
+ ],
+ [
+ "00000000000000a9846dea5cb42c990fc532488d38e92ee7faccf66f91fcdaa1",
+ 0
+ ],
+ [
+ "000000000000004084ee64746c686cb4a021ad22a97588f6c503188db6d1d917",
+ 0
+ ],
+ [
+ "00000000000000142ce2586d6439bfafd0d53d32ff41c1d56602e394f32edfda",
+ 0
+ ],
+ [
+ "0000000000000071fcc3a3cbd37aa7feb1c54580e532ef7219a9a24648b9ab09",
+ 0
+ ],
+ [
+ "000000000e155f0afa09985dc14fe86f8121819c6d5e455e7773e1b27290b2e6",
+ 0
+ ],
+ [
+ "0000000000001a44a33ef9aaa52457c6ba952ee215bf1c75afb31b51ec65a59b",
+ 0
+ ],
+ [
+ "000000000000001dab82737f9c30cd23319dc19f129f1a6a958e11443bf96c63",
+ 0
+ ],
+ [
+ "000000000000320acee35e29f344c28a6b0e61d930e3454b41160e02547fab38",
+ 0
+ ],
+ [
+ "00000000000023a7455cfc34eb472b2cf2e3cea92c075207aedb73a843683e5b",
+ 0
+ ],
+ [
+ "00000000000003cc17e82a58382a746c79ce383fe25387a18dc4a7162695b24b",
+ 0
+ ],
+ [
+ "00000000000004a50ed62cf7fb18ee8d8375e9df71fa6577ea66690d2560d769",
+ 0
+ ],
+ [
+ "00000000000011c599d9481072c314e1d6f9dd7ae862a8c8afae2777b71d0340",
+ 0
+ ],
+ [
+ "0000000000001b1e2885bf70ac03266d7bbd77d99da7216be9d7e3055281243a",
+ 0
+ ],
+ [
+ "0000000000002f9ffc72d5ed2db1dbd5d4421e848a486ec0740cc23099a1d6d7",
+ 0
+ ],
+ [
+ "000000000000181ad0f3edb9bd9dbff6a4bc4081c72e6882ecb0a7ced233037e",
+ 0
+ ],
+ [
+ "0000000000000360d9d309f32e14dc4dc2cccc57715bcd0afcfaab3c305fea59",
+ 0
+ ],
+ [
+ "00000000000001954c349d2b1dfe936f34ec475aad948cb5c1033ca5d0a7e385",
+ 0
+ ],
+ [
+ "00000000000000800aedc44c721f51e6ac502ecac49734e1e085d6f04f45d265",
+ 0
+ ],
+ [
+ "00000000cd7d09dda2b4d4116552fab663ad600ec316fb81fee153ec10e56da1",
+ 0
+ ],
+ [
+ "00000000000032520799302b06cec40a5181120451a0f61183a5c60d75c77a40",
+ 0
+ ],
+ [
+ "00000000000033982563fa344700e74b209035cadaa0ef965b99d1fb7b6dcd32",
+ 0
+ ],
+ [
+ "00000000000028b83e4a14503b6ec74f8333a77e02b4911af4fec814eef81695",
+ 0
+ ],
+ [
+ "0000000000006c0b30adc040e886bd7788d62a8b41ea1db9dcbc0799e6f2e737",
+ 0
+ ],
+ [
+ "000000000000014a918d5e373be7f26e3a140a7fae398f32479971242815aba2",
+ 0
+ ],
+ [
+ "0000000000002e992b9179f941e33aacc33e1fbc0d68f47ccf5df1f9156770b4",
+ 0
+ ],
+ [
+ "0000000000000d09de158157254cd426a88b8b61073a81775aa14b90df3d5133",
+ 0
+ ],
+ [
+ "0000000000006e2930da07f5be918af235aac0d8c51b20314399066a7ae9b8db",
+ 0
+ ],
+ [
+ "0000000000007d6168adc3577a27c3093c228d1d29b924e44ee6553d19627e13",
+ 0
+ ],
+ [
+ "000000000000195f3294a3e529151b1fbdeec735d0ca2f2787714dc387392753",
+ 0
+ ],
+ [
+ "0000000000000101d519d0775e4fddd0c73d80a210cbfe720899f8cbe41976bf",
+ 0
+ ],
+ [
+ "00000000000000133a76f07bc3fd48a608e48a3e4cda84e577c8b923bb224f47",
+ 0
+ ],
+ [
+ "00000000c577e64709e648ee42fa3dba41d59175acdcd99b644ba7b5ca2e5c40",
+ 0
+ ],
+ [
+ "0000000000001b8dbc7720c23866311b9599e45256b568286099d90661c90e3d",
+ 0
+ ],
+ [
+ "00000000000005caae0c34a171ff49b9e887357644f44a4917ffccdb1f42dbc4",
+ 0
+ ],
+ [
+ "00000000002f91f1dd7cfb25d8ee2f61a995e50d0ec22b077c042e1de7b5f6d9",
+ 0
+ ],
+ [
+ "0000000000002dec1164348f1b13e7198015ebb69d21d17f1db540f9f82254d2",
+ 0
+ ],
+ [
+ "000000000002082aabff07e386e279c12fe27d45e9929b13ee218bcd42cc917a",
+ 0
+ ],
+ [
+ "00000000000016e4462971bf4c30bcef675d766829063f337523bbdf39ad2dc8",
+ 0
+ ],
+ [
+ "0000000000000ff9b10fecfaf454ecc47fe228d9f8412b1e24aa3c9634109660",
+ 0
+ ],
+ [
+ "000000000000262c9ede4171783f97e7f559fd7dbdcb95e7d89414f3e91774e9",
+ 0
+ ],
+ [
+ "00000000000020a280fe02e039f9ac8f443afcc0f17869f2bbbe9f983b7ae4f8",
+ 0
+ ],
+ [
+ "0000000000000d214d8dbb194e92c0de0d817ccde5d3662b20bf41c39ba7c46c",
+ 0
+ ],
+ [
+ "00000000000001140f819a403f931fe6ead3c996781715ae1a2bae7108964dd1",
+ 0
+ ],
+ [
+ "000000000000006d98a1ac170738d4d9d31173f18d3c4eea36b8b2b39d6c4dc6",
+ 0
+ ],
+ [
+ "0000000057a23ba17eab7ea1ac8f5607497d80f17add1683166cb7dcc4bfb1ef",
+ 0
+ ],
+ [
+ "0000000000003a5db5ddfc931eb257c1fd8240f56289b987ee7e12563ece7e91",
+ 0
+ ],
+ [
+ "00000000000000ef1e4978348aa888a63d97fb20961e22f51b88e80c73a0adf6",
+ 0
+ ],
+ [
+ "00000000000000ded525277b70d1c22012dbef28f1abcc4519c9b1330d1c6b98",
+ 0
+ ],
+ [
+ "00000000001884ae12a9ddd3a274af90e28fe77c7d53d4e25ca614cde1aba3ae",
+ 0
+ ],
+ [
+ "00000000003db05cf4d82f61a1d52cc5d958c21f516aa3c348d39a4d7aa37f3c",
+ 0
+ ],
+ [
+ "0000000000121061c34c8807ba8036093628ada5e046d36d257a2b520d29bd30",
+ 0
+ ],
+ [
+ "0000000000001c7d96178988db6aa1450281a8208b67ba6da8d02e25a9ad9cd2",
+ 0
+ ],
+ [
+ "00000000000038a153341ef0baee829b4e5c6208710b145cd4492f644b825ddc",
+ 0
+ ],
+ [
+ "0000000000000d2f02bf068bd1344713ac3a52dae68b2431d860e85ba727dcbb",
+ 0
+ ],
+ [
+ "0000000000000535659de712f2344e83fc893955480c4e0d18a9d959c4ae35e2",
+ 0
+ ],
+ [
+ "000000000000033daebb675ae26fe3e3a2d0fcbf281f70fb40d1b1b1f41104bb",
+ 0
+ ],
+ [
+ "00000000000001373a277a18b25690db1720f038e373e45b56bfe4402418a293",
+ 0
+ ],
+ [
+ "000000000000002e92327500efa91d3c0352f394b2c6b205e1b2a8a0323f1ac4",
+ 0
+ ],
+ [
+ "00000000fdc454e72a6d98495fefe2197fea1ad03e6202c23843cce9a2962f09",
+ 0
+ ],
+ [
+ "0000000000009d979e5a4dfd64efe714f96e3a77f9296a917d6b109e76457c36",
+ 0
+ ],
+ [
+ "0000000000001cdafba80309177c007468e7a5e9512663ad5247659194f18802",
+ 0
+ ],
+ [
+ "0000000000018ff4e57f8865ea7de7725896df3ab4e5c6077b0a6c20df6511bd",
+ 0
+ ],
+ [
+ "0000000000040f4902037267b28a2ee8e87dca565d23f0e9723dd437782c77b1",
+ 0
+ ],
+ [
+ "0000000000004397345295d78ae41518a4af486f7ebc990dcc67bb3750c828ab",
+ 0
+ ],
+ [
+ "0000000000002174f6cdf300e2f9c2706850e0dea1f0119d9b0914328b93a4bb",
+ 0
+ ],
+ [
+ "0000000000039a00fb8ff791b9b2d83ba37485801d856bd9e7925ae540e4dc5e",
+ 0
+ ],
+ [
+ "00000000000004694e5fba3428cd48c9858cf6932f64be4f290c95fd9e393063",
+ 0
+ ],
+ [
+ "00000000000019a0ae2b62d65095889aa0fe28a6629f465e51cc06d56061d134",
+ 0
+ ],
+ [
+ "00000000000005be0e01d3cfb0e689bc7d9fe50a74f3dbb8e5cedc5792f8f1c7",
+ 0
+ ],
+ [
+ "00000000000000227b2f3dd83533e9fefb68bc59d62ec71244acf616552533ef",
+ 0
+ ],
+ [
+ "000000000000000bdf712efe085711c72387eaa76f5d79606d135d147bba6fa5",
+ 0
+ ],
+ [
+ "000000009f5b37f19334d54e6a778650ddfa021ed10b3f0948952c858f71aa11",
+ 0
+ ],
+ [
+ "000000000000389f3959130c192582eac8607121d2c8452166a4a715fb5c770b",
+ 0
+ ],
+ [
+ "000000000001b49aebd3997878570e8d1d78aa4062bdb83f72b178faea1e1599",
+ 0
+ ],
+ [
+ "000000000000ce62dcb191b681f6d552389dabb663c0cfe0680c6e308a443ac4",
+ 0
+ ],
+ [
+ "00000000000053cfbd088bd7c065ff1b24bdd0b343d976de7d5f0da1b2cd19d9",
+ 0
+ ],
+ [
+ "000000000002e5e6a1f262f0dffb01eba71ff07acb5ff901d8e977d8fc17322f",
+ 0
+ ],
+ [
+ "000000000001b494fefce702f5ea369d5dc91b4e517c570b656a83795bc69e44",
+ 0
+ ],
+ [
+ "000000000000c42a983d3e4a9d7b331ef07831d89e321cf28bfb9e049bffcaa7",
+ 0
+ ],
+ [
+ "000000000000d69faa189d6bece30215641349a60f7eef40c28b3e0d8fafdb3c",
+ 0
+ ],
+ [
+ "000000000000062f8df83420fc0db7bc81dcabafa3de817eae9a7988f727e4f5",
+ 0
+ ],
+ [
+ "00000000000001b2ef133ac5d1c35068e8a28a35bf39527ec6e9fe1c22faa94f",
+ 0
+ ],
+ [
+ "00000000000001b0a6a20b50dcab2725f528f062e1b8eef6ce04bf1398cca5b9",
+ 0
+ ],
+ [
+ "000000000000a1a77fd497e4166f9d49336e2fa1910be398e99e673a8c164db2",
+ 0
+ ],
+ [
+ "0000000000025e2694cc21be786db74fd365dbb0f4da514148ad99e4f2ebee1a",
+ 0
+ ],
+ [
+ "0000000000030422a5adcc3e6715937cf36b180d78a5171a786413afe5cf92c6",
+ 0
+ ],
+ [
+ "00000000000267cfac724ff60632456a778438c2754351694c8924f4bbcd5c79",
+ 0
+ ],
+ [
+ "000000000001c6b251f0b7a1dab3490af8bd59c132c47c92093e385a654d00b7",
+ 0
+ ],
+ [
+ "000000000002a52cea59e1aee38b14ab4df4a61d3c33dcf239c617ba0c2a8414",
+ 0
+ ],
+ [
+ "0000000000000035326f862d2769a4179098a9790328285e41c25c591f3d8b47",
+ 0
+ ],
+ [
+ "0000000000035473af9fbe2ddfbec0f70188b341f330329c5b0bcaa5159de523",
+ 0
+ ],
+ [
+ "00000000000007f012e2d4c8a02bc44092f4c2caf2a8b42db8111df9a40c301d",
+ 0
+ ],
+ [
+ "0000000000003dbab565a793a2174c70e3d9bd059a72d70d5962f33afb0508e6",
+ 0
+ ],
+ [
+ "0000000000000875cfa1b9ef3980e432e0b94e631fbbe26c5cf4be48d1f80d5f",
+ 0
+ ],
+ [
+ "00000000000000d7aac746ab42e51f5ea6bda208909be3b73ee35b1d529f0549",
+ 0
+ ],
+ [
+ "000000000000006b4a360564ade70833bc8ca4077e54693f58c7d658fa42060e",
+ 0
+ ],
+ [
+ "0000000000002d474eabb223a62c70ae60261eb7e1b60774ab7b89bd1b4e862b",
+ 0
+ ],
+ [
+ "0000000070ac5425cbc994b059cd7e379e600cd608256ca371c6bdcd80673e70",
+ 0
+ ],
+ [
+ "000000000000157983c64e148ee82a1b956b3dfc5961e4193c6ed9a01babf7aa",
+ 0
+ ],
+ [
+ "0000000000001bdb3697b4cf9c73e5494a6aceab8e26a767af183a14adfbee96",
+ 0
+ ],
+ [
+ "0000000000001024636de30482825e12a55ab284beac76dd32bd84008e7a56dc",
+ 0
+ ],
+ [
+ "000000000000077901852d1720e9fe55b317b1d707acfe7eeead14ca39d3e858",
+ 0
+ ],
+ [
+ "0000000000064673f804cd7023f8d6a4167c96a058fa2f6abeb8c52b23b8e48c",
+ 0
+ ],
+ [
+ "00000000000d74afb5c3e78596646e72c459064f491fcac9392f4662191fad85",
+ 0
+ ],
+ [
+ "00000000000284052da60a4948011dd9b8b47ee9600070c167a3fd38269089f9",
+ 0
+ ],
+ [
+ "00000000e8160e59aea361907f43ae001e0f57f81be07d8fae7eedaa0ac7e874",
+ 0
+ ],
+ [
+ "0000000000351cc9f437d474751a41873e425adfd90f1e04a685b62b22b735ef",
+ 0
+ ],
+ [
+ "000000000019ec9bd658f85ef2a8fb8b7117cbab8224b3f6da58ec84323283b7",
+ 0
+ ],
+ [
+ "000000000018fe65ad2c59fd8c42a9f8b8355e09339c68bed4dfb8dc7a96c464",
+ 0
+ ],
+ [
+ "000000000011ea14747c257f6a6b70fd8ffa1d1acb450ce487d75e0afdcc4a6c",
+ 0
+ ],
+ [
+ "0000000000112673666bbeb30ac2600f5c33f50dcd3f45a84a0b369c82b2265a",
+ 0
+ ],
+ [
+ "000000000006fc35a1b46ee63f58174344798c3945aeff136b18696fc8b37db9",
+ 0
+ ],
+ [
+ "0000000000041cabad64c90b9a6011cc275bc4274d4483c9d08c61354f61f87b",
+ 0
+ ],
+ [
+ "000000000000c49853970e4dcc57747e09c025b20a1b5399feea8e4da52e838f",
+ 0
+ ],
+ [
+ "0000000000005bdae23cd7d0fd132f647baeff5bcf27c128af9d4452918ab935",
+ 0
+ ],
+ [
+ "00000000000005f2a7320a92d69ca7a43fd50c0f33663a3620fd3b1997d7ee07",
+ 0
+ ],
+ [
+ "00000000000000fdb92b762b90c620b7fbcfd638d2cb5885970e0d0bc78b39dc",
+ 0
+ ],
+ [
+ "00000000000000f8d6ac6a65d665cd5dec111aa7ce9d35e8ec8e1f35838be3ac",
+ 0
+ ],
+ [
+ "000000000000000c4d57eea373335af6bd32da3ff748277e80fc8c8448a50363",
+ 0
+ ],
+ [
+ "000000004b67788b148a9ec401da418e9b3d498064158e9c05a15e88fff770ea",
+ 0
+ ],
+ [
+ "000000000133f2f73a74c399ffe67f7a3556db333a70eb3cba385e3c8a8a0c2d",
+ 0
+ ],
+ [
+ "000000000c4ed1736d5cce4a0ec7633800645cd1b9a5d360fb68df326f48df14",
+ 0
+ ],
+ [
+ "000000000950c87b961f40073b6b940765037a331dc0f7abbfd4c2a93db2def7",
+ 0
+ ],
+ [
+ "000000000000333379fa00b0b1ec72dfe6177075d25800caae3bbea5ce0ed6fb",
+ 0
+ ],
+ [
+ "0000000000002b26a1064652fe46da8356565adcc084ea5998dee2b19de66dfb",
+ 0
+ ],
+ [
+ "00000000000077c470e5a167d683ea0584a8f7a4b20c63ad5ce502f054cee10b",
+ 0
+ ],
+ [
+ "0000000000001583cd8eb780acf4ea25ff4d915d02ba99ca280b26ed5b4ca7bd",
+ 0
+ ],
+ [
+ "0000000000007e9819e4ef5116a0a1ecfd973e15de5786879a3538fe9070de0c",
+ 0
+ ],
+ [
+ "000000000000352b18af7591d822bc692a5a360cfee0d261d53f91e5d221bbe4",
+ 0
+ ],
+ [
+ "0000000000001e7f3b9aefdb3dd75677b1b182467941550bab8cbf7906a555cb",
+ 0
+ ],
+ [
+ "0000000000000fb3fd1f154a1669e779555793c5d1d1c18cab76cbfae70b27d2",
+ 0
+ ],
+ [
+ "00000000000003b68cfba858740b14b9493bad9e6eb3bea3a4fcbe08cca911f4",
+ 0
+ ],
+ [
+ "000000000000002a909cd56c02a14c89deb9acd9ce0e28a0bc34aecb4ad9921b",
+ 0
+ ],
+ [
+ "000000000000008316ea71503de71d0f539ee3cce6b1fb3106ec49a580cdf012",
+ 0
+ ],
+ [
+ "00000000c8cd6b15785e1789435a1fae16fc4b6528144c5d49c5b071dd16b0dd",
+ 0
+ ],
+ [
+ "0000000000001837a16eae4f4d934fba9bd58abee864ee10c1a6768f13e276ba",
+ 0
+ ],
+ [
+ "0000000000003559bab6ad82347a5bcd62b07572a7de146234a373b9d09b5bc8",
+ 0
+ ],
+ [
+ "00000000000020cbeb38ca68af925b736cd6a0694a8152b5ea9c6b06af972e9d",
+ 0
+ ],
+ [
+ "00000000000886977197c1d7ce749af9951e7cb08c448eb1c1034509048793b7",
+ 0
+ ],
+ [
+ "00000000002b307d0991eab1cc694be1eb3c607ebf1ff0fadfedbb038baabac6",
+ 0
+ ],
+ [
+ "00000000000c6570eaea754f2f6075ea71a68b46c90c08b271d08009eaefdfc6",
+ 0
+ ],
+ [
+ "0000000000004a6b77b80f36d0b4c843642c472b14060c659db079488394a548",
+ 0
+ ],
+ [
+ "00000000000026c176ab34dee51c7d6bcd101ace82b9d3a27cae9158fe4615e7",
+ 0
+ ],
+ [
+ "00000000000008740ccd0cd6f875599e851bb50daa188da402dc2e82cbe63432",
+ 0
+ ],
+ [
+ "000000000000027e5f3c4cc7fb4929504cf0a67192473b936ae4ffad6121eba8",
+ 0
+ ],
+ [
+ "0000000000000528bf19fababa130f28a8082d004750ff42ca1c7f2bde6be14e",
+ 0
+ ],
+ [
+ "000000000000014455b175ee9e2218fea02a4f64ca4219dda6cb8014c266fdc6",
+ 0
+ ]
+]
\ No newline at end of file
diff --git a/electrum/chains/testnet/fallback_lnnodes.json b/electrum/chains/testnet/fallback_lnnodes.json
new file mode 100644
index 000000000000..341973ab94d1
--- /dev/null
+++ b/electrum/chains/testnet/fallback_lnnodes.json
@@ -0,0 +1,54 @@
+{
+ "038863cf8ab91046230f561cd5b386cbff8309fa02e3f0c3ed161a3aeb64a643b9": {
+ "host": "203.132.95.10",
+ "port": 9735
+ },
+ "03236a685d30096b26692dce0cf0fa7c8528bdf61dbf5363a3ef6d5c92733a3016": {
+ "host": "50.116.3.223",
+ "port": 9734
+ },
+ "03d5e17a3c213fe490e1b0c389f8cfcfcea08a29717d50a9f453735e0ab2a7c003": {
+ "host": "3.16.119.191",
+ "port": 9735
+ },
+ "03933884aaf1d6b108397e5efe5c86bcf2d8ca8d2f700eda99db9214fc2712b134": {
+ "host": "34.250.234.192",
+ "port": 9735
+ },
+ "0260d9119979caedc570ada883ff614c6efb93f7f7382e25d73ecbeba0b62df2d7": {
+ "host": "88.99.209.230",
+ "port": 9735
+ },
+ "023ea0a53af875580899da0ab0a21455d9c19160c4ea1b7774c9d4be6810b02d2c": {
+ "host": "160.16.233.215",
+ "port": 9735
+ },
+ "0269a94e8b32c005e4336bfb743c08a6e9beb13d940d57c479d95c8e687ccbdb9f": {
+ "host": "197.155.6.173",
+ "port": 9735
+ },
+ "030f0bf260acdbd3edcad84d7588ec7c5df4711e87e6a23016f989b8d3a4147230": {
+ "host": "163.172.94.64",
+ "port": 9735
+ },
+ "02312627fdf07fbdd7e5ddb136611bdde9b00d26821d14d94891395452f67af248": {
+ "host": "23.237.77.12",
+ "port": 9735
+ },
+ "02ae2f22b02375e3e9b4b4a2db4f12e1b50752b4062dbefd6e01332acdaf680379": {
+ "host": "197.155.6.172",
+ "port": 9735
+ },
+ "034fe52e98a0e9d3c21b767e1b371881265d8c7578c21f5afd6d6438da10348b36": {
+ "host": "23.239.23.44",
+ "port": 9740
+ },
+ "02889be42fc32093d2dcbfa59369df262e3577b333d8a45e5859dcdd6a4139839a": {
+ "host": "2a09:8280:1::42:a6f3",
+ "port": 9735
+ },
+ "021713d5331898c206b57c4f7d40635079de9a97d97782646f31dac18a53f2d979": {
+ "host": "2a09:8280:1::15:a57c",
+ "port": 9735
+ }
+}
\ No newline at end of file
diff --git a/electrum/chains/testnet/servers.json b/electrum/chains/testnet/servers.json
new file mode 100644
index 000000000000..4545520870da
--- /dev/null
+++ b/electrum/chains/testnet/servers.json
@@ -0,0 +1,55 @@
+{
+ "3tc6nefii2fwoc66dqvrwcyj64dd3r35ihgxvp4u37itsopns5fjtead.onion": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.6"
+ },
+ "blackie.c3-soft.com": {
+ "pruning": "-",
+ "s": "57006",
+ "t": "57005",
+ "version": "1.4.5"
+ },
+ "blockstream.info": {
+ "pruning": "-",
+ "s": "993",
+ "t": "143",
+ "version": "1.4"
+ },
+ "electrum.blockstream.info": {
+ "pruning": "-",
+ "s": "60002",
+ "t": "60001",
+ "version": "1.4"
+ },
+ "explorerzydxu5ecjrkwceayqybizmpjjznk5izmitf2modhcusuqlid.onion": {
+ "pruning": "-",
+ "t": "143",
+ "version": "1.4"
+ },
+ "gsw6sn27quwf6u3swgra6o7lrp5qau6kt3ymuyoxgkth6wntzm2bjwyd.onion": {
+ "pruning": "-",
+ "s": "51002",
+ "t": "51001",
+ "version": "1.6"
+ },
+ "testnet.aranguren.org": {
+ "pruning": "-",
+ "s": "51002",
+ "t": "51001",
+ "version": "1.6"
+ },
+ "testnet.qtornado.com": {
+ "pruning": "-",
+ "s": "51002",
+ "t": "51001",
+ "version": "1.6.0"
+ },
+ "v22019051929289916.bestsrv.de": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.6"
+ }
+}
diff --git a/electrum/chains/testnet4/checkpoints.json b/electrum/chains/testnet4/checkpoints.json
new file mode 100644
index 000000000000..c32673534fb3
--- /dev/null
+++ b/electrum/chains/testnet4/checkpoints.json
@@ -0,0 +1,242 @@
+[
+ [
+ "00000000962a7fc2ef639196051fe181ed53ac6aa4cdfead14dca90f58aa36bc",
+ 0
+ ],
+ [
+ "000000002ad661157c553c0bbbb2490407adb1c8ac09f2b2a7174f87eeeb64bf",
+ 0
+ ],
+ [
+ "000000000be3ff43cde9eed4d6b2d4ad16c4f9509ccb94e1001af68e2f6647b3",
+ 0
+ ],
+ [
+ "00000000001ef2e4c2fc174354ed357cf313725fc336092733b2699d36342ff8",
+ 0
+ ],
+ [
+ "000000000025269f9fa4b0832ccbfef682d59c0fa8845b0c22cc24a1973f011a",
+ 0
+ ],
+ [
+ "000000000014b2d6b2ad804d5deb8d5b4a58caf152f6cea5600af0d9348dff29",
+ 0
+ ],
+ [
+ "000000000003c067c302d43c9499da6e382260252a2a29caf9748ee6972d5f01",
+ 0
+ ],
+ [
+ "000000000002180d23f15ba0b8161d9d38d03c61ab51d050c57928e1a7d98e0c",
+ 0
+ ],
+ [
+ "000000000000ed8722220a13b09d968a59686af5fc5c1e0a86371a498209fa72",
+ 0
+ ],
+ [
+ "0000000000003e82df3830ff7c05a58745a463a59d1097e160e47ac7aeb5323a",
+ 0
+ ],
+ [
+ "0000000000000c3f18b9a30269c4b53dd107bacf20482e4ec660e9970999a99f",
+ 0
+ ],
+ [
+ "00000000000002901853780dc8a63efd4d72359d8de7e14dc0398ccfc53d45cd",
+ 0
+ ],
+ [
+ "00000000000000a6ff1615113d25eeed8554813e4994f8ef7ce96458083d14cf",
+ 0
+ ],
+ [
+ "000000000000006e2d4fa8204c67c0986f9bb0214990b11043d0653d50755f54",
+ 0
+ ],
+ [
+ "00000000eaf8e0ea253d833614892aed70c55e5dc4b4d6709dd6420b8284debb",
+ 0
+ ],
+ [
+ "0000000000000063d3ca489d113ded6196c99f3785b61a8ded9254ebb96bc765",
+ 0
+ ],
+ [
+ "000000000000003f684cab6cdb7fe6e98cb13318bb45acdc2d2e2d7405b8bcbe",
+ 0
+ ],
+ [
+ "000000000000001f735b5a23732fb201cf6343b373c94a35f04e6b6075591889",
+ 0
+ ],
+ [
+ "000000001c247a1eb479ecc56ea7d7529f0c4afb6b7025f437a7d235454cd6a4",
+ 0
+ ],
+ [
+ "00000000acd1400a4801f361d675644993ad05e5b735a881f26746ece767521e",
+ 0
+ ],
+ [
+ "00000000542792e54a720567ba66157d48cdae7bfd01c1b678d0f07a2ed56e99",
+ 0
+ ],
+ [
+ "00000000ca301f565989627133247615bc937b52c68f8f4b342b6c2aeebff7ba",
+ 0
+ ],
+ [
+ "00000000e4ad2ec95dfddce6a554f626c9995e465b067e72528f6ae164fc58d2",
+ 0
+ ],
+ [
+ "00000000761cd6bff5e11258943e401e1bb094a8013e810e1d6031ce273a4b7c",
+ 0
+ ],
+ [
+ "00000000082032b915c151f1bb9892fe924013539924a34ec8b9bdea16eb7374",
+ 0
+ ],
+ [
+ "0000000000853161fa2a440407ce597524e85db6a27f5dffd35162b38e7627e7",
+ 0
+ ],
+ [
+ "00000000001c7ef1bace4a08448d9e462ff8b2e8c389f16026834c5d0c97252c",
+ 0
+ ],
+ [
+ "000000000ae11eebf9807d5ac0f9966282c59a1e613e229aa2bf7aea266d4535",
+ 0
+ ],
+ [
+ "00000000973e9926efc6a2a11413f7125242911ae10925e3616e872307a3e401",
+ 0
+ ],
+ [
+ "0000000000000003d66460a7e1ed89080427ac2004b2b3adae05e6e89725dc1f",
+ 0
+ ],
+ [
+ "000000009f8fe2dbafd82a0432d69d620d0ebeea033c8a2621bf06a6e6d3b5c4",
+ 0
+ ],
+ [
+ "0000000000000003ac6ce3118b61709d347142cc04daeb2c973b937904e4390b",
+ 0
+ ],
+ [
+ "0000000000003844aab8ac81fb69b47e83c037cee505bfd5cb6522aa4d2351bc",
+ 0
+ ],
+ [
+ "00000000000060397e5918c319db04ffac74f2a5c7724083112fca8958ea54de",
+ 0
+ ],
+ [
+ "00000000213b70c1bcec26b90a5503960a95d7a5fa5d9de498b9d794afeaebd5",
+ 0
+ ],
+ [
+ "0000000000000e5f474161d1b68932ab607035f5026f1ac4aa5816f24e76017a",
+ 0
+ ],
+ [
+ "00000000b56d4faf52043bfe98e126c69fb51671ba9bcea67c191620255d291d",
+ 0
+ ],
+ [
+ "0000000000052b94098008985919f6b525ead0d5cb5608fd60015f2647701ea1",
+ 0
+ ],
+ [
+ "00000000e5c06639c50bdb710b84ebf58d1df666db033157db685ec592932276",
+ 0
+ ],
+ [
+ "000000006f596242c0b5dd79b5f638e95a215252c44374133a5b263fcb5e9f89",
+ 0
+ ],
+ [
+ "00000000a2dc7b8c63c737543dd41e5a7a529d1824c32e2d454cae998c5a7298",
+ 0
+ ],
+ [
+ "000000008a995172d20119cc9b48a28ea4bf350711c2925849e5db760d0bde05",
+ 0
+ ],
+ [
+ "000000000bcec41b64702945a8cc7aceb06bc51fa93528b39619180ddc03e9bd",
+ 0
+ ],
+ [
+ "00000000003f767fd29141200c4b64fc0b224edcf28ef65f66c6345b76a60f32",
+ 0
+ ],
+ [
+ "00000000046883bd13b616c3525b243bbf3c7a9688a66f8ce46506ec25f6e798",
+ 0
+ ],
+ [
+ "00000000068670ee282a076d17900a07da93da50b3705405b1e7ca8616856535",
+ 0
+ ],
+ [
+ "0000000026b1f5d8ffa89385b048ca9004b97b412eee44000810ea8177a4bef4",
+ 0
+ ],
+ [
+ "0000000000000002e72644dda2132c93ac54edd6c155f47c51ca8cf4b918c0bf",
+ 0
+ ],
+ [
+ "00000000000000000a20f50c208d1ae3c3397fc6059b2cf1fe6b698e42023178",
+ 0
+ ],
+ [
+ "000000000a0b5c3e57dd4c3b751d13562a92953d794e19aff8f9e2ca6607604f",
+ 0
+ ],
+ [
+ "0000000003b9f6443b23bf86f200ef85005dfc508564960812eb85774327e6ce",
+ 0
+ ],
+ [
+ "000000000668f8e866d39a9ec07937e716a6035a02cfd0757ee716ac7cdca2fd",
+ 0
+ ],
+ [
+ "00000000041c239cab44d0afc2014a1351250375e796c692298ba8a8fcdb41de",
+ 0
+ ],
+ [
+ "0000000008c8148de5f2a96c0b82b32f8726ef7061386d370a68e078ade8e3e5",
+ 0
+ ],
+ [
+ "00000000089663c835fe83325ffaced64ad9c924468d1e1339f400bf8ed57883",
+ 0
+ ],
+ [
+ "00000000039229460de251e6b8b303841f76a1bc022025e8ca66a6ace20ffdf3",
+ 0
+ ],
+ [
+ "000000006b606bff2dfa618d2975fdfc2bea491f558ebe16be2ca7a0aa1c0181",
+ 0
+ ],
+ [
+ "0000000005dfc1e853b8d644598b77bdd97ef7578f9ccbb840aa3860fda9ddac",
+ 0
+ ],
+ [
+ "0000000002e9de570afba91146a43378199cc3b38fd52b2f4f1989734d22084a",
+ 0
+ ],
+ [
+ "000000000d1359300f79d95e0db59f4c14099684718838720db7569d98348844",
+ 0
+ ]
+]
\ No newline at end of file
diff --git a/electrum/chains/testnet4/servers.json b/electrum/chains/testnet4/servers.json
new file mode 100644
index 000000000000..43a1095dbfbd
--- /dev/null
+++ b/electrum/chains/testnet4/servers.json
@@ -0,0 +1,43 @@
+{
+ "134.199.227.217": {
+ "pruning": "-",
+ "s": "50002",
+ "t": "50001",
+ "version": "1.6.0"
+ },
+ "bitcoin.stagemole.eu": {
+ "pruning": "-",
+ "s": "5010",
+ "t": "5000",
+ "version": "1.6.0"
+ },
+ "blackie.c3-soft.com": {
+ "pruning": "-",
+ "s": "57010",
+ "t": "57009",
+ "version": "1.6"
+ },
+ "fulcrum.theuplink.net": {
+ "pruning": "-",
+ "s": "60002",
+ "version": "1.6"
+ },
+ "gsw6sn27quwf6u3swgra6o7lrp5qau6kt3ymuyoxgkth6wntzm2bjwyd.onion": {
+ "pruning": "-",
+ "s": "52002",
+ "t": "52001",
+ "version": "1.5.3"
+ },
+ "testnet4.qtornado.com": {
+ "pruning": "-",
+ "s": "51012",
+ "t": "51011",
+ "version": "1.6.0"
+ },
+ "v22019051929289916.bestsrv.de": {
+ "pruning": "-",
+ "s": "60002",
+ "t": "60001",
+ "version": "1.6"
+ }
+}
diff --git a/electrum/channel_db.py b/electrum/channel_db.py
new file mode 100644
index 000000000000..387b9a3546b2
--- /dev/null
+++ b/electrum/channel_db.py
@@ -0,0 +1,1183 @@
+# -*- coding: utf-8 -*-
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2018 The Electrum developers
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import ipaddress
+import time
+import random
+import os
+from collections import defaultdict
+from typing import Sequence, List, Tuple, Optional, Dict, NamedTuple, TYPE_CHECKING, Set
+import binascii
+import base64
+import asyncio
+import threading
+from enum import IntEnum
+import functools
+
+from aiorpcx import NetAddress
+from electrum_ecc import ECPubkey
+
+from .sql_db import SqlDB, sql
+from . import constants, util
+from .util import profiler, get_headers_dir, is_ip_address, json_normalize, UserFacingException, is_private_netaddress
+from .lntransport import LNPeerAddr
+from .lnutil import (ShortChannelID, validate_features, IncompatibleOrInsaneFeatures, LnFeatureContexts,
+ InvalidGossipMsg, GossipForwardingMessage, GossipTimestampFilter)
+from .lnverifier import LNChannelVerifier, verify_sig_for_channel_update
+from .lnmsg import decode_msg
+from .crypto import sha256d
+from .lnmsg import FailedToParseMsg
+
+if TYPE_CHECKING:
+ from .network import Network
+ from .lnchannel import Channel
+ from .lnrouter import RouteEdge
+ from .simple_config import SimpleConfig
+
+
+FLAG_DISABLE = 1 << 1
+FLAG_DIRECTION = 1 << 0
+
+
+class ChannelDBNotLoaded(UserFacingException): pass
+
+
+class ChannelInfo(NamedTuple):
+ short_channel_id: ShortChannelID
+ node1_id: bytes
+ node2_id: bytes
+ capacity_sat: Optional[int]
+ raw: Optional[bytes] = None
+
+ @staticmethod
+ def from_msg(payload: dict) -> 'ChannelInfo':
+ features = int.from_bytes(payload['features'], 'big')
+ features = validate_features(features, context=LnFeatureContexts.CHAN_ANN_AS_IS)
+ channel_id = payload['short_channel_id']
+ node_id_1 = payload['node_id_1']
+ node_id_2 = payload['node_id_2']
+ assert list(sorted([node_id_1, node_id_2])) == [node_id_1, node_id_2]
+ capacity_sat = None
+ return ChannelInfo(
+ short_channel_id = ShortChannelID.normalize(channel_id),
+ node1_id = node_id_1,
+ node2_id = node_id_2,
+ capacity_sat = capacity_sat,
+ raw = payload.get('raw')
+ )
+
+ @staticmethod
+ def from_raw_msg(raw: bytes) -> 'ChannelInfo':
+ payload_dict = decode_msg(raw)[1]
+ payload_dict['raw'] = raw
+ return ChannelInfo.from_msg(payload_dict)
+
+ @staticmethod
+ def from_route_edge(route_edge: 'RouteEdge') -> 'ChannelInfo':
+ node1_id, node2_id = sorted([route_edge.start_node, route_edge.end_node])
+ return ChannelInfo(
+ short_channel_id=route_edge.short_channel_id,
+ node1_id=node1_id,
+ node2_id=node2_id,
+ capacity_sat=None,
+ )
+
+
+class Policy(NamedTuple):
+ key: bytes
+ cltv_delta: int
+ htlc_minimum_msat: int
+ htlc_maximum_msat: Optional[int]
+ fee_base_msat: int
+ fee_proportional_millionths: int
+ channel_flags: int
+ message_flags: int
+ timestamp: int
+ raw: Optional[bytes] = None
+
+ @staticmethod
+ def from_msg(payload: dict) -> 'Policy':
+ return Policy(
+ key = payload['short_channel_id'] + payload['start_node'],
+ cltv_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 = payload['timestamp'],
+ raw = payload.get('raw'),
+ )
+
+ @staticmethod
+ def from_raw_msg(key: bytes, raw: bytes) -> 'Policy':
+ payload = decode_msg(raw)[1]
+ payload['start_node'] = key[8:]
+ payload['raw'] = raw
+ return Policy.from_msg(payload)
+
+ @staticmethod
+ def from_route_edge(route_edge: 'RouteEdge') -> 'Policy':
+ return Policy(
+ key=route_edge.short_channel_id + route_edge.start_node,
+ cltv_delta=route_edge.cltv_delta,
+ htlc_minimum_msat=0,
+ htlc_maximum_msat=None,
+ fee_base_msat=route_edge.fee_base_msat,
+ fee_proportional_millionths=route_edge.fee_proportional_millionths,
+ channel_flags=0,
+ message_flags=0,
+ timestamp=0,
+ )
+
+ def is_disabled(self):
+ return self.channel_flags & FLAG_DISABLE
+
+ @property
+ def short_channel_id(self) -> ShortChannelID:
+ return ShortChannelID.normalize(self.key[0:8])
+
+ @property
+ def start_node(self) -> bytes:
+ return self.key[8:]
+
+
+class NodeInfo(NamedTuple):
+ node_id: bytes
+ features: int
+ timestamp: int
+ alias: str
+ raw: Optional[bytes]
+
+ @staticmethod
+ def from_msg(payload) -> Tuple['NodeInfo', Sequence['LNPeerAddr']]:
+ node_id = payload['node_id']
+ features = int.from_bytes(payload['features'], "big")
+ features = validate_features(features, context=LnFeatureContexts.NODE_ANN)
+ 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')
+ try:
+ alias = alias.decode('utf8')
+ except Exception:
+ alias = ''
+ timestamp = payload['timestamp']
+ node_info = NodeInfo(
+ node_id=node_id,
+ features=features,
+ timestamp=timestamp,
+ alias=alias,
+ raw=payload.get('raw'))
+ return node_info, peer_addrs
+
+ @staticmethod
+ def from_raw_msg(raw: bytes) -> Tuple['NodeInfo', Sequence['LNPeerAddr']]:
+ payload_dict = decode_msg(raw)[1]
+ payload_dict['raw'] = raw
+ return NodeInfo.from_msg(payload_dict)
+
+ @staticmethod
+ def to_addresses_field(hostname: str, port: int) -> bytes:
+ """Encodes a hostname/port pair into a BOLT-7 'addresses' field."""
+ if (NodeInfo.invalid_announcement_hostname(hostname)
+ or port is None or port <= 0 or port > 65535):
+ return b''
+ port_bytes = port.to_bytes(2, 'big')
+ if is_ip_address(hostname): # ipv4 or ipv6
+ ip_addr = ipaddress.ip_address(hostname)
+ if ip_addr.version == 4:
+ return b'\x01' + ip_addr.packed + port_bytes
+ elif ip_addr.version == 6:
+ return b'\x02' + ip_addr.packed + port_bytes
+ return b''
+ elif hostname.endswith('.onion'): # Tor onion v3
+ onion_addr: bytes = base64.b32decode(hostname[:-6], casefold=True)
+ return b'\x04' + onion_addr + port_bytes
+ else:
+ try:
+ hostname_ascii: bytes = hostname.encode('ascii')
+ except UnicodeEncodeError:
+ # encoding single characters to punycode (according to spec) doesn't make sense
+ # as you can't differentiate them from regular ascii? encoding the whole string to punycode
+ # doesn't work either as the receiver would interpret it as regular ascii.
+ # hostname_ascii: bytes = hostname.encode('punycode')
+ return b''
+ if len(hostname_ascii) + 3 > 258: # + 1 byte for length and 2 for port
+ return b'' # too long
+ return b'\x05' + len(hostname_ascii).to_bytes(1, "big") + hostname_ascii + port_bytes
+
+ @staticmethod
+ def invalid_announcement_hostname(hostname: Optional[str]) -> bool:
+ """Returns True if hostname unsuited for publishing in a NodeAnnouncement."""
+ if (hostname is None or hostname == ""
+ or is_private_netaddress(hostname)
+ or hostname.startswith("http://") # not catching 'http' due to onion addresses
+ or hostname.startswith("https://")):
+ return True
+ if hostname.endswith('.onion'):
+ if len(hostname) != 62: # not an onion v3 link (probably onion v2)
+ return True
+ return False
+
+ @staticmethod
+ def parse_addresses_field(addresses_field):
+ buf = addresses_field
+
+ def read(n):
+ nonlocal buf
+ data, buf = buf[0:n], buf[n:]
+ return data
+
+ addresses = []
+ while buf:
+ atype = ord(read(1))
+ if atype == 0:
+ pass
+ elif atype == 1: # IPv4
+ ipv4_addr = '.'.join(map(lambda x: '%d' % x, read(4)))
+ port = int.from_bytes(read(2), 'big')
+ if is_ip_address(ipv4_addr) and port != 0:
+ addresses.append((ipv4_addr, port))
+ elif atype == 2: # IPv6
+ ipv6_addr = b':'.join([binascii.hexlify(read(2)) for i in range(8)])
+ ipv6_addr = ipv6_addr.decode('ascii')
+ port = int.from_bytes(read(2), 'big')
+ if is_ip_address(ipv6_addr) and port != 0:
+ addresses.append((ipv6_addr, port))
+ elif atype == 3: # onion v2
+ read(12) # we skip onion v2 as it is deprecated
+ elif atype == 4: # onion v3
+ host = base64.b32encode(read(35)) + b'.onion'
+ host = host.decode('ascii').lower()
+ port = int.from_bytes(read(2), 'big')
+ addresses.append((host, port))
+ elif atype == 5: # dns hostname
+ len_hostname = int.from_bytes(read(1), 'big')
+ host = read(len_hostname).decode('ascii')
+ port = int.from_bytes(read(2), 'big')
+ if not NodeInfo.invalid_announcement_hostname(host) and port > 0:
+ addresses.append((host, port))
+ else:
+ # unknown address type
+ # we don't know how long it is -> have to escape
+ # if there are other addresses we could have parsed later, they are lost.
+ break
+ return addresses
+
+
+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
+
+
+def get_mychannel_info(short_channel_id: ShortChannelID,
+ my_channels: Dict[ShortChannelID, 'Channel']) -> Optional[ChannelInfo]:
+ chan = my_channels.get(short_channel_id)
+ if not chan:
+ return
+ raw_msg, _ = chan.construct_channel_announcement_without_sigs()
+ ci = ChannelInfo.from_raw_msg(raw_msg)
+ return ci._replace(capacity_sat=chan.constraints.capacity)
+
+
+def get_mychannel_policy(short_channel_id: bytes, node_id: bytes,
+ my_channels: Dict[ShortChannelID, 'Channel']) -> Optional[Policy]:
+ 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)
+
+
+class _LoadDataAborted(Exception): pass
+
+
+create_channel_info = """
+CREATE TABLE IF NOT EXISTS channel_info (
+short_channel_id BLOB(8),
+msg BLOB,
+PRIMARY KEY(short_channel_id)
+)"""
+
+create_policy = """
+CREATE TABLE IF NOT EXISTS policy (
+key BLOB(41),
+msg BLOB,
+PRIMARY KEY(key)
+)"""
+
+create_address = """
+CREATE TABLE IF NOT EXISTS address (
+node_id BLOB(33),
+host STRING(256),
+port INTEGER NOT NULL,
+timestamp INTEGER,
+PRIMARY KEY(node_id, host, port)
+)"""
+
+create_node_info = """
+CREATE TABLE IF NOT EXISTS node_info (
+node_id BLOB(33),
+msg BLOB,
+PRIMARY KEY(node_id)
+)"""
+
+
+class ChannelDB(SqlDB):
+
+ NUM_MAX_RECENT_PEERS = 20
+ PRIVATE_CHAN_UPD_CACHE_TTL_NORMAL = 600
+ PRIVATE_CHAN_UPD_CACHE_TTL_SHORT = 120
+
+ def __init__(self, network: 'Network'):
+ path = self.get_file_path(network.config)
+ super().__init__(network.asyncio_loop, path, commit_interval=100)
+ self.lock = threading.RLock()
+ self.num_nodes = 0
+ self.num_channels = 0
+ self.num_policies = 0
+ self._channel_updates_for_private_channels = {} # type: Dict[Tuple[bytes, bytes], Tuple[dict, int]]
+ # note: ^ we could maybe move this cache into PaySession instead of being global.
+ # That would only make sense though if PaySessions were never too short
+ # (e.g. consider trampoline forwarding).
+ self.ca_verifier = LNChannelVerifier(network, self)
+
+ # initialized in load_data
+ # 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 -> NetAddress -> timestamp
+ self._addresses = defaultdict(dict) # type: Dict[bytes, Dict[NetAddress, int]]
+ 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.forwarding_lock = threading.RLock()
+ self.fwd_channels = [] # type: List[GossipForwardingMessage]
+ self.fwd_orphan_channels = [] # type: List[GossipForwardingMessage]
+ self.fwd_channel_updates = [] # type: List[GossipForwardingMessage]
+ self.fwd_node_announcements = [] # type: List[GossipForwardingMessage]
+
+ self.data_loaded = asyncio.Event()
+ self.network = network # only for callback
+
+ @classmethod
+ def get_file_path(cls, config: 'SimpleConfig') -> str:
+ return os.path.join(get_headers_dir(config), 'gossip_db')
+
+ def update_counts(self):
+ self.num_nodes = len(self._nodes)
+ self.num_channels = len(self._channels)
+ self.num_policies = len(self._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):
+ with self.lock:
+ return set(self._channels.keys())
+
+ def add_recent_peer(self, peer: LNPeerAddr):
+ now = int(time.time())
+ node_id = peer.pubkey
+ with self.lock:
+ self._addresses[node_id][peer.net_addr()] = 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):
+ with self.lock:
+ unshuffled = set(self._nodes.keys()) - node_ids
+ return random.sample(list(unshuffled), min(200, len(unshuffled)))
+
+ def get_last_good_address(self, node_id: bytes) -> Optional[LNPeerAddr]:
+ """Returns latest address we successfully connected to, for given node."""
+ addr_to_ts = self._addresses.get(node_id)
+ if not addr_to_ts:
+ return None
+ addr = sorted(list(addr_to_ts), key=lambda a: addr_to_ts[a], reverse=True)[0]
+ try:
+ return LNPeerAddr(str(addr.host), addr.port, node_id)
+ except ValueError:
+ return None
+
+ def get_recent_peers(self):
+ if not self.data_loaded.is_set():
+ raise ChannelDBNotLoaded("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 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.
+ def add_channel_announcements(self, msg_payloads, *, trusted=True):
+ # note: signatures have already been verified.
+ if type(msg_payloads) is dict:
+ msg_payloads = [msg_payloads]
+ added = 0
+ for msg in msg_payloads:
+ short_channel_id = ShortChannelID(msg['short_channel_id'])
+ if short_channel_id in self._channels:
+ continue
+ if constants.net.rev_genesis_bytes() != msg['chain_hash']:
+ self.logger.info("ChanAnn has unexpected chain_hash {}".format(msg['chain_hash'].hex()))
+ continue
+ try:
+ channel_info = ChannelInfo.from_msg(msg)
+ except IncompatibleOrInsaneFeatures as e:
+ self.logger.info(f"unknown or insane feature bits: {e!r}")
+ continue
+ if trusted:
+ added += 1
+ self.add_verified_channel_info(msg)
+ else:
+ added += self.ca_verifier.add_new_channel_info(short_channel_id, msg)
+
+ self.update_counts()
+
+ def add_verified_channel_info(self, msg: dict, *, capacity_sat: int = None) -> None:
+ try:
+ channel_info = ChannelInfo.from_msg(msg)
+ except IncompatibleOrInsaneFeatures:
+ return
+ channel_info = channel_info._replace(capacity_sat=capacity_sat)
+ 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'])
+ with self.forwarding_lock:
+ if fwd_msg := GossipForwardingMessage.from_payload(msg):
+ self.fwd_channels.append(fwd_msg)
+
+ def policy_changed(self, old_policy: Policy, new_policy: Policy, verbose: bool) -> bool:
+ changed = False
+ if old_policy.cltv_delta != new_policy.cltv_delta:
+ changed |= True
+ if verbose:
+ self.logger.info(f'cltv_expiry_delta: {old_policy.cltv_delta} -> {new_policy.cltv_delta}')
+ if 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:
+ 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:
+ 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:
+ 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:
+ 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:
+ 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_update(
+ self, payload, *, max_age=None, verify=True, verbose=True) -> UpdateStatus:
+ 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
+ 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:
+ if policy.message_flags & 0b10 == 0: # check if its `dont_forward`
+ with self.forwarding_lock:
+ if fwd_msg := GossipForwardingMessage.from_payload(payload):
+ self.fwd_channel_updates.append(fwd_msg)
+ return UpdateStatus.GOOD
+
+ def add_channel_updates(self, payloads, max_age=None) -> CategorizedChannelUpdates:
+ orphaned = []
+ expired = []
+ deprecated = []
+ unchanged = []
+ good = []
+ for payload in payloads:
+ r = self.add_channel_update(payload, max_age=max_age, verbose=False, verify=True)
+ if r == UpdateStatus.ORPHANED:
+ orphaned.append(payload)
+ elif r == UpdateStatus.EXPIRED:
+ expired.append(payload)
+ elif r == UpdateStatus.DEPRECATED:
+ deprecated.append(payload)
+ 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,
+ unchanged=unchanged,
+ good=good)
+
+ def create_database(self):
+ c = self.conn.cursor()
+ c.execute(create_node_info)
+ c.execute(create_address)
+ c.execute(create_policy)
+ c.execute(create_channel_info)
+ self.conn.commit()
+
+ @sql
+ 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, msg) VALUES (?,?)""", [key, msg])
+
+ @sql
+ 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 _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, msg) VALUES (?,?)", [short_channel_id, msg])
+
+ @sql
+ 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 _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, msg) VALUES (?,?)", [node_id, msg])
+
+ @sql
+ 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 (?,?,?,?)",
+ (peer.pubkey, peer.host, peer.port, timestamp))
+
+ @sql
+ 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.pubkey, addr.host, addr.port))
+ r = c.fetchall()
+ if r == []:
+ c.execute("INSERT INTO address (node_id, host, port, timestamp) VALUES (?,?,?,?)", (addr.pubkey, addr.host, addr.port, 0))
+
+ @classmethod
+ def verify_channel_update(cls, payload, *, start_node: bytes = None) -> None:
+ short_channel_id = payload['short_channel_id']
+ short_channel_id = ShortChannelID(short_channel_id)
+ if constants.net.rev_genesis_bytes() != payload['chain_hash']:
+ raise InvalidGossipMsg('wrong chain hash')
+ start_node = payload.get('start_node', None) or start_node
+ assert start_node is not None
+ if not verify_sig_for_channel_update(payload, start_node):
+ raise InvalidGossipMsg(f'failed verifying channel update for {short_channel_id}')
+
+ @classmethod
+ def verify_channel_announcement(cls, payload) -> None:
+ h = sha256d(payload['raw'][2+256:])
+ pubkeys = [payload['node_id_1'], payload['node_id_2'], payload['bitcoin_key_1'], payload['bitcoin_key_2']]
+ sigs = [payload['node_signature_1'], payload['node_signature_2'], payload['bitcoin_signature_1'], payload['bitcoin_signature_2']]
+ for pubkey, sig in zip(pubkeys, sigs):
+ if not ECPubkey(pubkey).ecdsa_verify(sig, h):
+ raise InvalidGossipMsg('signature failed')
+
+ @classmethod
+ def verify_node_announcement(cls, payload) -> None:
+ pubkey = payload['node_id']
+ signature = payload['signature']
+ h = sha256d(payload['raw'][66:])
+ if not ECPubkey(pubkey).ecdsa_verify(signature, h):
+ raise InvalidGossipMsg('signature failed')
+
+ def add_node_announcements(self, msg_payloads):
+ # note: signatures have already been verified.
+ if type(msg_payloads) is dict:
+ msg_payloads = [msg_payloads]
+ new_nodes = set() # type: Set[bytes]
+ for msg_payload in msg_payloads:
+ try:
+ node_info, node_addresses = NodeInfo.from_msg(msg_payload)
+ except IncompatibleOrInsaneFeatures:
+ continue
+ node_id = node_info.node_id
+ # Ignore node if it has no associated channel (DoS protection)
+ if node_id not in self._channels_for_node:
+ #self.logger.info('ignoring orphan node_announcement')
+ continue
+ node = self._nodes.get(node_id)
+ if node and node.timestamp >= node_info.timestamp:
+ continue
+ new_nodes.add(node_id)
+ # save
+ 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:
+ net_addr = NetAddress(addr.host, addr.port)
+ self._addresses[node_id][net_addr] = self._addresses[node_id].get(net_addr) or 0
+ self._db_save_node_addresses(node_addresses)
+ with self.forwarding_lock:
+ if fwd_msg := GossipForwardingMessage.from_payload(msg_payload):
+ self.fwd_node_announcements.append(fwd_msg)
+
+ self.update_counts()
+
+ 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 _policies.items() if v.timestamp <= now - delta)
+
+ @profiler(min_threshold=0.2)
+ def prune_old_policies(self, delta):
+ 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(old_policies)} old policies')
+
+ @profiler(min_threshold=0.2)
+ def prune_orphaned_channels(self):
+ 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(orphaned_chans)} orphaned channels')
+
+ def _get_channel_update_for_private_channel(
+ self,
+ start_node_id: bytes,
+ short_channel_id: ShortChannelID,
+ *,
+ now: int = None, # unix ts
+ ) -> Optional[dict]:
+ if now is None:
+ now = int(time.time())
+ key = (start_node_id, short_channel_id)
+ chan_upd_dict, cache_expiration = self._channel_updates_for_private_channels.get(key, (None, 0))
+ if cache_expiration < now:
+ chan_upd_dict = None # already expired
+ # TODO rm expired entries from cache (note: perf vs thread-safety)
+ return chan_upd_dict
+
+ def add_channel_update_for_private_channel(
+ self,
+ msg_payload: dict,
+ start_node_id: bytes,
+ *,
+ cache_ttl: int = None, # seconds
+ ) -> bool:
+ """Returns True iff the channel update was successfully added and it was different than
+ what we had before (if any).
+ """
+ if not verify_sig_for_channel_update(msg_payload, start_node_id):
+ return False # ignore
+ now = int(time.time())
+ short_channel_id = ShortChannelID(msg_payload['short_channel_id'])
+ msg_payload['start_node'] = start_node_id
+ prev_chanupd = self._get_channel_update_for_private_channel(start_node_id, short_channel_id, now=now)
+ if prev_chanupd == msg_payload:
+ return False
+ if cache_ttl is None:
+ cache_ttl = self.PRIVATE_CHAN_UPD_CACHE_TTL_NORMAL
+ cache_expiration = now + cache_ttl
+ key = (start_node_id, short_channel_id)
+ with self.lock:
+ self._channel_updates_for_private_channels[key] = msg_payload, cache_expiration
+ return True
+
+ def remove_channel(self, short_channel_id: ShortChannelID):
+ # 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._db_delete_channel(short_channel_id)
+
+ def get_node_addresses(self, node_id: bytes) -> Sequence[Tuple[str, int, int]]:
+ """Returns list of (host, port, timestamp)."""
+ addr_to_ts = self._addresses.get(node_id)
+ if not addr_to_ts:
+ return []
+ return [(str(net_addr.host), net_addr.port, ts)
+ for net_addr, ts in addr_to_ts.items()]
+
+ def handle_abort(func):
+ @functools.wraps(func)
+ def wrapper(self: 'ChannelDB', *args, **kwargs):
+ try:
+ return func(self, *args, **kwargs)
+ except _LoadDataAborted:
+ return
+ return wrapper
+
+ @sql
+ @profiler
+ @handle_abort
+ def load_data(self):
+ if self.data_loaded.is_set():
+ return
+
+ # Note: this method takes several seconds... mostly due to lnmsg.decode_msg being slow.
+ def maybe_abort():
+ if self.stopping:
+ self.logger.info("load_data() was asked to stop. exiting early.")
+ raise _LoadDataAborted()
+ c = self.conn.cursor()
+ c.execute("""SELECT * FROM address""")
+ for x in c:
+ maybe_abort()
+ node_id, host, port, timestamp = x
+ try:
+ net_addr = NetAddress(host, port)
+ except Exception:
+ continue
+ self._addresses[node_id][net_addr] = int(timestamp or 0)
+
+ def newest_ts_for_node_id(node_id):
+ newest_ts = 0
+ for addr, ts in self._addresses[node_id].items():
+ 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 short_channel_id, msg in c:
+ maybe_abort()
+ try:
+ ci = ChannelInfo.from_raw_msg(msg)
+ except IncompatibleOrInsaneFeatures:
+ continue
+ except FailedToParseMsg:
+ continue
+ self._channels[ShortChannelID.normalize(short_channel_id)] = ci
+ c.execute("""SELECT * FROM node_info""")
+ for node_id, msg in c:
+ maybe_abort()
+ try:
+ node_info, node_addresses = NodeInfo.from_raw_msg(msg)
+ except IncompatibleOrInsaneFeatures:
+ continue
+ except FailedToParseMsg:
+ continue
+ # don't load node_addresses because they dont have timestamps
+ self._nodes[node_id] = node_info
+ c.execute("""SELECT * FROM policy""")
+ for key, msg in c:
+ maybe_abort()
+ try:
+ p = Policy.from_raw_msg(key, msg)
+ except FailedToParseMsg:
+ continue
+ 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'data loaded. {len(self._channels)} chans. {len(self._policies)} policies. '
+ f'{len(self._channels_for_node)} nodes.')
+ self.update_counts()
+ (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.asyncio_loop.call_soon_threadsafe(self.data_loaded.set)
+ util.trigger_callback('gossip_db_loaded')
+
+ 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_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_policy_for_node(
+ self,
+ short_channel_id: ShortChannelID,
+ node_id: bytes,
+ *,
+ my_channels: Dict[ShortChannelID, 'Channel'] = None,
+ private_route_edges: Dict[ShortChannelID, 'RouteEdge'] = None,
+ now: int = None, # unix ts
+ ) -> 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
+ elif chan_upd_dict := self._get_channel_update_for_private_channel(node_id, short_channel_id, now=now):
+ return Policy.from_msg(chan_upd_dict)
+ # check if it's one of our own channels
+ if my_channels:
+ policy = get_mychannel_policy(short_channel_id, node_id, my_channels)
+ if policy:
+ return policy
+ if private_route_edges:
+ route_edge = private_route_edges.get(short_channel_id, None)
+ if route_edge:
+ return Policy.from_route_edge(route_edge)
+
+ def get_channel_info(
+ self,
+ short_channel_id: ShortChannelID,
+ *,
+ my_channels: Dict[ShortChannelID, 'Channel'] = None,
+ private_route_edges: Dict[ShortChannelID, 'RouteEdge'] = None,
+ ) -> Optional[ChannelInfo]:
+ ret = self._channels.get(short_channel_id)
+ if ret:
+ return ret
+ # check if it's one of our own channels
+ if my_channels:
+ channel_info = get_mychannel_info(short_channel_id, my_channels)
+ if channel_info:
+ return channel_info
+ if private_route_edges:
+ route_edge = private_route_edges.get(short_channel_id)
+ if route_edge:
+ return ChannelInfo.from_route_edge(route_edge)
+
+ def get_channels_for_node(
+ self,
+ node_id: bytes,
+ *,
+ my_channels: Dict[ShortChannelID, 'Channel'] = None,
+ private_route_edges: Dict[ShortChannelID, 'RouteEdge'] = None,
+ ) -> Set[ShortChannelID]:
+ """Returns the set of short channel IDs where node_id is one of the channel participants."""
+ if not self.data_loaded.is_set():
+ raise ChannelDBNotLoaded("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?
+ if my_channels:
+ for chan in my_channels.values():
+ if node_id in (chan.node_id, chan.get_local_pubkey()):
+ relevant_channels.add(chan.short_channel_id)
+ # add private channels # TODO maybe slow?
+ if private_route_edges:
+ for route_edge in private_route_edges.values():
+ if node_id in (route_edge.start_node, route_edge.end_node):
+ relevant_channels.add(route_edge.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)
+
+ def get_node_infos(self) -> Dict[bytes, NodeInfo]:
+ with self.lock:
+ return self._nodes.copy()
+
+ def get_node_policies(self) -> Dict[Tuple[bytes, ShortChannelID], Policy]:
+ with self.lock:
+ return self._policies.copy()
+
+ def get_node_by_prefix(self, prefix):
+ with self.lock:
+ for k in self._addresses.keys():
+ if k.startswith(prefix):
+ return k
+ raise Exception('node not found')
+
+ def clear_forwarding_gossip(self) -> None:
+ with self.forwarding_lock:
+ self.fwd_channels.clear()
+ self.fwd_channel_updates.clear()
+ self.fwd_node_announcements.clear()
+
+ def filter_orphan_channel_anns(
+ self, channel_anns: List[GossipForwardingMessage]
+ ) -> Tuple[List, List]:
+ """Check if the channel announcements we want to forward have at least 1 update"""
+ to_forward_anns = []
+ orphaned_channel_anns = []
+ for channel in channel_anns:
+ if channel.scid is None:
+ continue
+ elif (channel.scid in self._chans_with_1_policies
+ or channel.scid in self._chans_with_2_policies):
+ to_forward_anns.append(channel)
+ continue
+ orphaned_channel_anns.append(channel)
+ return to_forward_anns, orphaned_channel_anns
+
+ def set_fwd_channel_anns_ts(self, channel_anns: List[GossipForwardingMessage]) \
+ -> List[GossipForwardingMessage]:
+ """Set the timestamps of the passed channel announcements from the corresponding policies"""
+ timestamped_chan_anns: List[GossipForwardingMessage] = []
+ with self.lock:
+ policies = self._policies.copy()
+ channels = self._channels.copy()
+
+ for chan_ann in channel_anns:
+ if chan_ann.timestamp is not None:
+ timestamped_chan_anns.append(chan_ann)
+ continue
+
+ scid = chan_ann.scid
+ if (channel_info := channels.get(scid)) is None:
+ continue
+
+ policy1 = policies.get((channel_info.node1_id, scid))
+ policy2 = policies.get((channel_info.node2_id, scid))
+ potential_timestamps = []
+ for policy in [policy1, policy2]:
+ if policy is not None:
+ potential_timestamps.append(policy.timestamp)
+ if not potential_timestamps:
+ continue
+ chan_ann.timestamp = min(potential_timestamps)
+ timestamped_chan_anns.append(chan_ann)
+ return timestamped_chan_anns
+
+ def get_forwarding_gossip_batch(self) -> List[GossipForwardingMessage]:
+ with self.forwarding_lock:
+ fwd_gossip = self.fwd_channel_updates + self.fwd_node_announcements
+ channel_anns = self.fwd_channels.copy()
+ self.clear_forwarding_gossip()
+
+ fwd_chan_anns1, _ = self.filter_orphan_channel_anns(self.fwd_orphan_channels)
+ fwd_chan_anns2, self.fwd_orphan_channels = self.filter_orphan_channel_anns(channel_anns)
+ channel_anns = self.set_fwd_channel_anns_ts(fwd_chan_anns1 + fwd_chan_anns2)
+ return channel_anns + fwd_gossip
+
+ def get_gossip_in_timespan(self, timespan: GossipTimestampFilter) \
+ -> List[GossipForwardingMessage]:
+ """Return a list of gossip messages matching the requested timespan."""
+ forwarding_gossip = []
+ with self.lock:
+ chans = self._channels.copy()
+ policies = self._policies.copy()
+ nodes = self._nodes.copy()
+
+ for short_id, chan in chans.items():
+ # fetching the timestamp from the channel update (according to BOLT-07)
+ chan_up_n1 = policies.get((chan.node1_id, short_id))
+ chan_up_n2 = policies.get((chan.node2_id, short_id))
+ updates = []
+ for policy in [chan_up_n1, chan_up_n2]:
+ if policy and policy.raw and timespan.in_range(policy.timestamp):
+ if policy.message_flags & 0b10 == 0: # check that its not "dont_forward"
+ updates.append(GossipForwardingMessage(
+ msg=policy.raw,
+ timestamp=policy.timestamp))
+ if not updates or chan.raw is None:
+ continue
+ chan_ann_ts = min(update.timestamp for update in updates)
+ channel_announcement = GossipForwardingMessage(msg=chan.raw, timestamp=chan_ann_ts)
+ forwarding_gossip.extend([channel_announcement] + updates)
+
+ for node_ann in nodes.values():
+ if timespan.in_range(node_ann.timestamp) and node_ann.raw:
+ forwarding_gossip.append(GossipForwardingMessage(
+ msg=node_ann.raw,
+ timestamp=node_ann.timestamp))
+ return forwarding_gossip
+
+ def get_channels_in_range(self, first_blocknum: int, number_of_blocks: int) -> List[ShortChannelID]:
+ with self.lock:
+ channels = self._channels.copy()
+ scids: List[ShortChannelID] = []
+ for scid in channels:
+ if first_blocknum <= scid.block_height < first_blocknum + number_of_blocks:
+ scids.append(scid)
+ scids.sort()
+ return scids
+
+ def get_gossip_for_scid_request(self, scid: ShortChannelID) -> List[bytes]:
+ requested_gossip = []
+
+ chan_ann = self._channels.get(scid)
+ if not chan_ann or not chan_ann.raw:
+ return []
+ chan_up1 = self._policies.get((chan_ann.node1_id, scid))
+ chan_up2 = self._policies.get((chan_ann.node2_id, scid))
+ node_ann1 = self._nodes.get(chan_ann.node1_id)
+ node_ann2 = self._nodes.get(chan_ann.node2_id)
+
+ for msg in [chan_ann, chan_up1, chan_up2, node_ann1, node_ann2]:
+ if msg and msg.raw:
+ requested_gossip.append(msg.raw)
+ return requested_gossip
+
+ def to_dict(self) -> dict:
+ """ Generates a graph representation in terms of a dictionary.
+
+ The dictionary contains only native python types and can be encoded
+ to json.
+ """
+ with self.lock:
+ graph = {'nodes': [], 'channels': []}
+
+ # gather nodes
+ for pk, nodeinfo in self._nodes.items():
+ # use _asdict() to convert NamedTuples to json encodable dicts
+ graph['nodes'].append(
+ nodeinfo._asdict(),
+ )
+ graph['nodes'][-1]['addresses'] = [
+ {'host': str(addr.host), 'port': addr.port, 'timestamp': ts}
+ for addr, ts in self._addresses[pk].items()
+ ]
+
+ # gather channels
+ for cid, channelinfo in self._channels.items():
+ graph['channels'].append(
+ channelinfo._asdict(),
+ )
+ policy1 = self._policies.get(
+ (channelinfo.node1_id, channelinfo.short_channel_id))
+ policy2 = self._policies.get(
+ (channelinfo.node2_id, channelinfo.short_channel_id))
+ graph['channels'][-1]['policy1'] = policy1._asdict() if policy1 else None
+ graph['channels'][-1]['policy2'] = policy2._asdict() if policy2 else None
+
+ # need to use json_normalize otherwise json encoding in rpc server fails
+ graph = json_normalize(graph)
+ return graph
diff --git a/electrum/coinchooser.py b/electrum/coinchooser.py
new file mode 100644
index 000000000000..843470f8de6f
--- /dev/null
+++ b/electrum/coinchooser.py
@@ -0,0 +1,548 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2015 kyuupichan@gmail
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+from collections import defaultdict
+from math import floor, log10
+from typing import NamedTuple, List, Callable, Sequence, Dict, Tuple, Mapping, Type, TYPE_CHECKING
+from decimal import Decimal
+
+from .bitcoin import sha256, COIN, is_address
+from .transaction import Transaction, PartialTransaction, PartialTxInput, PartialTxOutput
+from .util import NotEnoughFunds
+from .logging import Logger
+
+if TYPE_CHECKING:
+ from .simple_config import SimpleConfig
+
+
+# A simple deterministic PRNG. Used to deterministically shuffle a
+# set of coins - the same set of coins should produce the same output.
+# Although choosing UTXOs "randomly" we want it to be deterministic,
+# so if sending twice from the same UTXO set we choose the same UTXOs
+# to spend. This prevents attacks on users by malicious or stale
+# servers.
+class PRNG:
+ def __init__(self, seed):
+ self.sha = sha256(seed)
+ self.pool = bytearray()
+
+ def get_bytes(self, n: int) -> bytes:
+ while len(self.pool) < n:
+ self.pool.extend(self.sha)
+ self.sha = sha256(self.sha)
+ result, self.pool = self.pool[:n], self.pool[n:]
+ return bytes(result)
+
+ def randint(self, start, end):
+ # Returns random integer in [start, end)
+ n = end - start
+ r = 0
+ p = 1
+ while p < n:
+ r = self.get_bytes(1)[0] + (r << 8)
+ p = p << 8
+ return start + (r % n)
+
+ def choice(self, seq):
+ return seq[self.randint(0, len(seq))]
+
+ def shuffle(self, x):
+ for i in reversed(range(1, len(x))):
+ # pick an element in x[:i+1] with which to exchange x[i]
+ j = self.randint(0, i+1)
+ x[i], x[j] = x[j], x[i]
+
+
+class Bucket(NamedTuple):
+ desc: str
+ weight: int # as in BIP-141
+ value: int # in satoshis
+ effective_value: int # estimate of value left after subtracting fees. in satoshis
+ coins: List[PartialTxInput] # UTXOs
+ min_height: int # min block height where a coin was confirmed
+ witness: bool # whether any coin uses segwit
+
+
+class ScoredCandidate(NamedTuple):
+ penalty: float
+ tx: PartialTransaction
+ buckets: List[Bucket]
+
+
+def strip_unneeded(bkts: List[Bucket], sufficient_funds: Callable) -> List[Bucket]:
+ '''Remove buckets that are unnecessary in achieving the spend amount'''
+ if sufficient_funds([], bucket_value_sum=0):
+ # none of the buckets are needed
+ return []
+ bkts = sorted(bkts, key=lambda bkt: bkt.value, reverse=True)
+ bucket_value_sum = 0
+ for i in range(len(bkts)):
+ bucket_value_sum += (bkts[i]).value
+ if sufficient_funds(bkts[:i+1], bucket_value_sum=bucket_value_sum):
+ return bkts[:i+1]
+ raise Exception("keeping all buckets is still not enough")
+
+
+class CoinChooserBase(Logger):
+
+ 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
+
+ def bucketize_coins(
+ self,
+ coins: Sequence[PartialTxInput],
+ *,
+ fee_estimator_vb: Callable[[int | float | Decimal], int],
+ ):
+ keys = self.keys(coins)
+ buckets = defaultdict(list) # type: Dict[str, List[PartialTxInput]]
+ for key, coin in zip(keys, coins):
+ buckets[key].append(coin)
+ # fee_estimator returns fee to be paid, for given vbytes.
+ # guess whether it is just returning a constant as follows.
+ constant_fee = fee_estimator_vb(2000) == fee_estimator_vb(200)
+
+ def make_Bucket(desc: str, coins: List[PartialTxInput]):
+ witness = any(coin.is_segwit(guess_for_address=True) for coin in coins)
+ # note that we're guessing whether the tx uses segwit based
+ # on this single bucket
+ weight = sum(Transaction.estimated_input_weight(coin, witness)
+ for coin in coins)
+ value = sum(coin.value_sats() for coin in coins)
+ min_height = min(coin.block_height for coin in coins)
+ assert min_height is not None
+ # the fee estimator is typically either a constant or a linear function,
+ # so the "function:" effective_value(bucket) will be homomorphic for addition
+ # i.e. effective_value(b1) + effective_value(b2) = effective_value(b1 + b2)
+ if constant_fee:
+ effective_value = value
+ else:
+ # when converting from weight to vBytes, instead of rounding up,
+ # keep fractional part, to avoid overestimating fee
+ fee = fee_estimator_vb(Decimal(weight) / 4)
+ effective_value = value - fee
+ return Bucket(desc=desc,
+ weight=weight,
+ value=value,
+ effective_value=effective_value,
+ coins=coins,
+ min_height=min_height,
+ witness=witness)
+
+ return list(map(make_Bucket, buckets.keys(), buckets.values()))
+
+ def penalty_func(
+ self,
+ base_tx: Transaction,
+ *,
+ tx_from_buckets: Callable[[List[Bucket]], Tuple[PartialTransaction, List[PartialTxOutput]]],
+ ) -> Callable[[List[Bucket]], ScoredCandidate]:
+ raise NotImplementedError
+
+ def _change_amounts(self, tx: PartialTransaction, count: int, fee_estimator_numchange) -> List[int]:
+ # Break change up if bigger than max_change
+ output_amounts = [o.value for o in tx.outputs()]
+ # Don't split change of less than 0.02 BTC
+ max_change = max([0.02 * COIN] + output_amounts) * 1.25
+
+ # Use N change outputs
+ for n in range(1, count + 1):
+ # How much is left if we add this many change outputs?
+ change_amount = max(0, tx.get_fee() - fee_estimator_numchange(n))
+ if change_amount // n <= max_change:
+ break
+
+ # Get a handle on the precision of the output amounts; round our
+ # change to look similar
+ def trailing_zeroes(val):
+ s = str(val)
+ return len(s) - len(s.rstrip('0'))
+
+ zeroes = [trailing_zeroes(i) for i in output_amounts]
+ min_zeroes = min([8] + zeroes)
+ max_zeroes = max([0] + zeroes)
+
+ if n > 1:
+ zeroes = range(max(0, min_zeroes - 1), (max_zeroes + 1) + 1)
+ else:
+ # if there is only one change output, this will ensure that we aim
+ # to have one that is exactly as precise as the most precise output
+ zeroes = [min_zeroes]
+
+ # Calculate change; randomize it a bit if using more than 1 output
+ remaining = change_amount
+ amounts = []
+ while n > 1:
+ average = remaining / n
+ amount = self.p.randint(int(average * 0.7), int(average * 1.3))
+ precision = min(self.p.choice(zeroes), int(floor(log10(amount))))
+ amount = int(round(amount, -precision))
+ amounts.append(amount)
+ remaining -= amount
+ n -= 1
+
+ # Last change output. Round down to maximum precision but lose
+ # no more than 10**max_dp_to_round_for_privacy
+ # e.g. a max of 2 decimal places means losing 100 satoshis to fees
+ # don't round if the fee estimator is set to 0 fixed fee, so a 0 fee tx remains a 0 fee tx
+ is_zero_fee_tx = True if fee_estimator_numchange(1) == 0 else False
+ output_value_rounding = self.enable_output_value_rounding and not is_zero_fee_tx
+ max_dp_to_round_for_privacy = 2 if output_value_rounding else 0
+ N = int(pow(10, min(max_dp_to_round_for_privacy, zeroes[0])))
+ amount = (remaining // N) * N
+ amounts.append(amount)
+
+ assert sum(amounts) <= change_amount
+
+ return amounts
+
+ def _change_outputs(self, tx: PartialTransaction, change_addrs, fee_estimator_numchange,
+ dust_threshold) -> List[PartialTxOutput]:
+ amounts = self._change_amounts(tx, len(change_addrs), fee_estimator_numchange)
+ assert min(amounts) >= 0
+ assert len(change_addrs) >= len(amounts)
+ assert all([isinstance(amt, int) for amt in amounts])
+ # If change is above dust threshold after accounting for the
+ # size of the change output, add it to the transaction.
+ amounts = [amount for amount in amounts if amount >= dust_threshold]
+ change = [PartialTxOutput.from_address_and_value(addr, amount)
+ for addr, amount in zip(change_addrs, amounts)]
+ for c in change:
+ c.is_change = True
+ return change
+
+ def _construct_tx_from_selected_buckets(
+ self, *, buckets: Sequence[Bucket],
+ base_tx: PartialTransaction, change_addrs,
+ fee_estimator_w, dust_threshold,
+ base_weight,
+ BIP69_sort: bool,
+ ) -> Tuple[PartialTransaction, List[PartialTxOutput]]:
+ # make a copy of base_tx so it won't get mutated
+ tx = PartialTransaction.from_io(base_tx.inputs()[:], base_tx.outputs()[:], BIP69_sort=BIP69_sort)
+
+ tx.add_inputs([coin for b in buckets for coin in b.coins], BIP69_sort=BIP69_sort)
+ tx_weight = self._get_tx_weight(buckets, base_weight=base_weight)
+
+ # change is sent back to sending address unless specified
+ if not change_addrs:
+ change_addrs = [tx.inputs()[0].address]
+ # note: this is not necessarily the final "first input address"
+ # because the inputs had not been sorted at this point
+ assert is_address(change_addrs[0])
+
+ # This takes a count of change outputs and returns a tx fee
+ output_weight = 4 * Transaction.estimated_output_size_for_address(change_addrs[0])
+ fee_estimator_numchange = lambda count: fee_estimator_w(tx_weight + count * output_weight)
+ change = self._change_outputs(tx, change_addrs, fee_estimator_numchange, dust_threshold)
+ tx.add_outputs(change, BIP69_sort=BIP69_sort)
+
+ return tx, change
+
+ def _get_tx_weight(self, buckets: Sequence[Bucket], *, base_weight: int) -> int:
+ """Given a collection of buckets, return the total weight of the
+ resulting transaction.
+ base_weight is the weight of the tx that includes the fixed (non-change)
+ outputs and potentially some fixed inputs. Note that the change outputs
+ at this point are not yet known so they are NOT accounted for.
+ """
+ total_weight = base_weight + sum(bucket.weight for bucket in buckets)
+ is_segwit_tx = any(bucket.witness for bucket in buckets)
+ if is_segwit_tx:
+ total_weight += 2 # marker and flag
+ # non-segwit inputs were previously assumed to have
+ # a witness of '' instead of '00' (hex)
+ # note that mixed legacy/segwit buckets are already ok
+ num_legacy_inputs = sum((not bucket.witness) * len(bucket.coins)
+ for bucket in buckets)
+ total_weight += num_legacy_inputs
+
+ return total_weight
+
+ def make_tx(
+ self, *,
+ coins: Sequence[PartialTxInput],
+ inputs: List[PartialTxInput],
+ outputs: List[PartialTxOutput],
+ change_addrs: Sequence[str],
+ fee_estimator_vb: Callable[[int | float | Decimal], int],
+ dust_threshold: int,
+ BIP69_sort: bool = True,
+ ) -> PartialTransaction:
+ """Select unspent coins to spend to pay outputs. If the change is
+ greater than dust_threshold (after adding the change output to
+ the transaction) it is kept, otherwise none is sent and it is
+ added to the transaction fee.
+
+ `inputs` and `outputs` are guaranteed to be a subset of the
+ inputs and outputs of the resulting transaction.
+ `coins` are further UTXOs we can choose from.
+
+ Note: fee_estimator_vb expects virtual bytes
+ """
+ # Deterministic randomness from coins
+ utxos = [c.prevout.serialize_to_network() for c in coins]
+ self.p = PRNG(b''.join(sorted(utxos)))
+
+ assert len(outputs) > 0 or len(change_addrs) == 1, \
+ "sweeps with 0 outputs should not use multiple change addresses"
+
+ # Copy the outputs so when adding change we don't modify "outputs"
+ base_tx = PartialTransaction.from_io(inputs[:], outputs[:], BIP69_sort=BIP69_sort)
+ input_value = base_tx.input_value()
+
+ # Weight of the transaction with no inputs and no change
+ # Note: this will use legacy tx serialization as the need for "segwit"
+ # would be detected from inputs. The only side effect should be that the
+ # marker and flag are excluded, which is compensated in get_tx_weight()
+ # FIXME calculation will be off by this (2 wu) in case of RBF batching
+ base_weight = base_tx.estimated_weight()
+ # by setting spent_amount = dust_threshold if there are no outputs we ensure that
+ # enough inputs are added so there is always at least a change output created
+ # as txs have to have at least 1 output according to consensus rules
+ spent_amount = base_tx.output_value() if outputs else dust_threshold
+
+ def fee_estimator_w(weight):
+ return fee_estimator_vb(Transaction.virtual_size_from_weight(weight))
+
+ def sufficient_funds(buckets: List[Bucket], *, bucket_value_sum: int) -> bool:
+ '''Given a list of buckets, return True if it has enough
+ value to pay for the transaction'''
+ # assert bucket_value_sum == sum(bucket.value for bucket in buckets) # expensive!
+ total_input = input_value + bucket_value_sum
+ if total_input < spent_amount: # shortcut for performance
+ return False
+ # any bitcoin tx must have at least 1 input by consensus
+ # (check we add some new UTXOs now or already have some fixed inputs)
+ if not buckets and not inputs:
+ return False
+ # note re performance: so far this was constant time
+ # what follows is linear in len(buckets)
+ total_weight = self._get_tx_weight(buckets, base_weight=base_weight)
+ return total_input >= spent_amount + fee_estimator_w(total_weight)
+
+ def tx_from_buckets(buckets):
+ return self._construct_tx_from_selected_buckets(
+ buckets=buckets,
+ base_tx=base_tx,
+ change_addrs=change_addrs,
+ fee_estimator_w=fee_estimator_w,
+ dust_threshold=dust_threshold,
+ base_weight=base_weight,
+ BIP69_sort=BIP69_sort,
+ )
+ # Collect the coins into buckets
+ all_buckets = self.bucketize_coins(coins, fee_estimator_vb=fee_estimator_vb)
+ # Filter some buckets out. Only keep those that have positive effective value.
+ # Note that this filtering is intentionally done on the bucket level
+ # instead of per-coin, as each bucket should be either fully spent or not at all.
+ # (e.g. CoinChooserPrivacy ensures that same-address coins go into one bucket)
+ all_buckets = list(filter(lambda b: b.effective_value > 0, all_buckets))
+ # Choose a subset of the buckets
+ scored_candidate = self.choose_buckets(all_buckets, sufficient_funds,
+ self.penalty_func(base_tx, tx_from_buckets=tx_from_buckets))
+ tx = scored_candidate.tx
+
+ self.logger.info(f"using {len(tx.inputs())} inputs")
+ self.logger.info(f"using buckets: {[bucket.desc for bucket in scored_candidate.buckets]}")
+
+ return tx
+
+ def choose_buckets(self, buckets: List[Bucket],
+ sufficient_funds: Callable,
+ penalty_func: Callable[[List[Bucket]], ScoredCandidate]) -> ScoredCandidate:
+ raise NotImplemented('To be subclassed')
+
+
+class CoinChooserRandom(CoinChooserBase):
+
+ def bucket_candidates_any(
+ self,
+ buckets: List[Bucket],
+ sufficient_funds: Callable,
+ ) -> List[List[Bucket]]:
+ '''Returns a list of bucket sets.'''
+ if not buckets:
+ if sufficient_funds([], bucket_value_sum=0):
+ return [[]]
+ else:
+ raise NotEnoughFunds()
+
+ candidates = set()
+
+ # Add all singletons
+ for n, bucket in enumerate(buckets):
+ if sufficient_funds([bucket], bucket_value_sum=bucket.value):
+ candidates.add((n,))
+
+ # And now some random ones
+ attempts = min(100, (len(buckets) - 1) * 10 + 1)
+ permutation = list(range(len(buckets)))
+ for i in range(attempts):
+ # Get a random permutation of the buckets, and
+ # incrementally combine buckets until sufficient
+ self.p.shuffle(permutation)
+ bkts = []
+ bucket_value_sum = 0
+ for count, index in enumerate(permutation):
+ bucket = buckets[index]
+ bkts.append(bucket)
+ bucket_value_sum += bucket.value
+ if sufficient_funds(bkts, bucket_value_sum=bucket_value_sum):
+ candidates.add(tuple(sorted(permutation[:count + 1])))
+ break
+ else:
+ # note: this assumes that the effective value of any bkt is >= 0
+ raise NotEnoughFunds()
+
+ candidates = [[buckets[n] for n in c] for c in candidates]
+ return [strip_unneeded(c, sufficient_funds) for c in candidates]
+
+ def bucket_candidates_prefer_confirmed(
+ self,
+ buckets: List[Bucket],
+ sufficient_funds: Callable,
+ ) -> List[List[Bucket]]:
+ """Returns a list of bucket sets preferring confirmed coins.
+
+ Any bucket can be:
+ 1. "confirmed" if it only contains confirmed coins; else
+ 2. "unconfirmed" if it does not contain coins with unconfirmed parents
+ 3. other: e.g. "unconfirmed parent" or "local"
+
+ This method tries to only use buckets of type 1, and if the coins there
+ are not enough, tries to use the next type but while also selecting
+ all buckets of all previous types.
+ """
+ conf_buckets = [bkt for bkt in buckets if bkt.min_height > 0]
+ unconf_buckets = [bkt for bkt in buckets if bkt.min_height == 0]
+ other_buckets = [bkt for bkt in buckets if bkt.min_height < 0]
+
+ bucket_sets = [conf_buckets, unconf_buckets, other_buckets]
+ already_selected_buckets = []
+ already_selected_buckets_value_sum = 0
+
+ for bkts_choose_from in bucket_sets:
+ try:
+ def sfunds(
+ bkts: List[Bucket], *, bucket_value_sum: int,
+ already_selected_buckets_value_sum=already_selected_buckets_value_sum,
+ already_selected_buckets=already_selected_buckets,
+ ):
+ bucket_value_sum += already_selected_buckets_value_sum
+ return sufficient_funds(already_selected_buckets + bkts,
+ bucket_value_sum=bucket_value_sum)
+
+ candidates = self.bucket_candidates_any(bkts_choose_from, sfunds)
+ break
+ except NotEnoughFunds:
+ already_selected_buckets += bkts_choose_from
+ already_selected_buckets_value_sum += sum(bucket.value for bucket in bkts_choose_from)
+ else:
+ raise NotEnoughFunds()
+
+ candidates = [(already_selected_buckets + c) for c in candidates]
+ return [strip_unneeded(c, sufficient_funds) for c in candidates]
+
+ def choose_buckets(self, buckets, sufficient_funds, penalty_func):
+ candidates = self.bucket_candidates_prefer_confirmed(buckets, sufficient_funds)
+ scored_candidates = [penalty_func(cand) for cand in candidates]
+ winner = min(scored_candidates, key=lambda x: x.penalty)
+ self.logger.info(f"Total number of buckets: {len(buckets)}")
+ self.logger.info(f"Num candidates considered: {len(candidates)}. "
+ f"Winning penalty: {winner.penalty}")
+ return winner
+
+
+class CoinChooserPrivacy(CoinChooserRandom):
+ """Attempts to better preserve user privacy.
+ First, if any coin is spent from a user address, all coins are.
+ Compared to spending from other addresses to make up an amount, this reduces
+ information leakage about sender holdings. It also helps to
+ reduce blockchain UTXO bloat, and reduce future privacy loss that
+ would come from reusing that address' remaining UTXOs.
+ Second, it penalizes change that is quite different to the sent amount.
+ Third, it penalizes change that is too big.
+ """
+
+ def keys(self, coins):
+ return [coin.scriptpubkey.hex() for coin in coins]
+
+ def penalty_func(self, base_tx, *, tx_from_buckets):
+ if _outputs := base_tx.outputs():
+ min_change = min(o.value for o in _outputs) * 0.75
+ max_change = max(o.value for o in _outputs) * 1.33
+ else:
+ min_change = 0
+ max_change = 0.02 * COIN
+
+ def penalty(buckets: List[Bucket]) -> ScoredCandidate:
+ # Penalize using many buckets (~inputs)
+ badness = len(buckets) - 1
+ tx, change_outputs = tx_from_buckets(buckets)
+ change = sum(o.value for o in change_outputs)
+ # Penalize change not roughly in output range
+ if change == 0:
+ pass # no change is great!
+ elif change < min_change:
+ badness += (min_change - change) / (min_change + 10000)
+ # Penalize really small change; under 1 mBTC ~= using 1 more input
+ if change < COIN / 1000:
+ badness += 1
+ elif change > max_change:
+ badness += (change - max_change) / (max_change + 10000)
+ # Penalize large change; 5 BTC excess ~= using 1 more input
+ badness += change / (COIN * 5)
+ return ScoredCandidate(badness, tx, buckets)
+
+ return penalty
+
+
+COIN_CHOOSERS = {
+ 'Privacy': CoinChooserPrivacy,
+} # type: Mapping[str, Type[CoinChooserBase]]
+
+
+def get_name(config: 'SimpleConfig') -> str:
+ kind = config.WALLET_COIN_CHOOSER_POLICY
+ if kind not in COIN_CHOOSERS:
+ kind = config.cv.WALLET_COIN_CHOOSER_POLICY.get_default_value()
+ return kind
+
+
+def get_coin_chooser(config: 'SimpleConfig') -> CoinChooserBase:
+ klass = COIN_CHOOSERS[get_name(config)]
+ # 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.WALLET_COIN_CHOOSER_OUTPUT_ROUNDING,
+ )
+ return coinchooser
diff --git a/electrum/commands.py b/electrum/commands.py
new file mode 100644
index 000000000000..a3776ed59206
--- /dev/null
+++ b/electrum/commands.py
@@ -0,0 +1,2569 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2011 thomasv@gitorious
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+import io
+import sys
+import datetime
+import time
+import argparse
+import json
+import ast
+import binascii
+import base64
+import asyncio
+import inspect
+from asyncio import CancelledError
+from collections import defaultdict
+from functools import wraps
+from decimal import Decimal, InvalidOperation
+from typing import Optional, TYPE_CHECKING, Dict, List, Any, Union
+import os
+import re
+
+import electrum_ecc as ecc
+
+from . import util
+from .lnmsg import OnionWireSerializer
+from .lnworker import LN_P2P_NETWORK_TIMEOUT
+from .logging import Logger
+from .onion_message import create_blinded_path, send_onion_message_to
+from .submarine_swaps import NostrTransport
+from .util import (
+ bfh, json_decode, json_normalize, is_hash256_str, is_hex_str, to_bytes, parse_max_spend, to_decimal,
+ UserFacingException, InvalidPassword
+)
+from . import bitcoin
+from .bitcoin import is_address, hash_160, COIN
+from .bip32 import BIP32Node
+from .i18n import _
+from .transaction import (
+ Transaction, multisig_script, PartialTransaction, PartialTxOutput, tx_from_any, PartialTxInput, TxOutpoint,
+ convert_raw_tx_to_hex
+)
+from . import transaction
+from .invoices import Invoice, PR_PAID, PR_UNPAID, PR_EXPIRED
+from .synchronizer import Notifier
+from .wallet import (
+ Abstract_Wallet, create_new_wallet, restore_wallet_from_text, Deterministic_Wallet, BumpFeeStrategy,
+ Imported_Wallet
+)
+from .address_synchronizer import TX_HEIGHT_LOCAL
+from .mnemonic import Mnemonic
+from .lnutil import (channel_id_from_funding_tx, LnFeatures, SENT, RECEIVED, MIN_FINAL_CLTV_DELTA_ACCEPTED,
+ PaymentFeeBudget, NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE)
+from .plugin import run_hook, DeviceMgr, Plugins
+from .version import ELECTRUM_VERSION
+from .simple_config import SimpleConfig
+from .fee_policy import FeePolicy, FEE_ETA_TARGETS, FEERATE_DEFAULT_RELAY
+from . import GuiImportError
+from . import crypto
+from . import constants
+from . import descriptor
+
+if TYPE_CHECKING:
+ from .network import Network
+ from .daemon import Daemon
+ from electrum.lnworker import PaymentInfo
+
+
+known_commands = {} # type: Dict[str, Command]
+
+
+class NotSynchronizedException(UserFacingException):
+ pass
+
+
+def satoshis_or_max(amount):
+ return satoshis(amount) if not parse_max_spend(amount) else amount
+
+
+def satoshis(amount):
+ # satoshi conversion must not be performed by the parser
+ return int(COIN*to_decimal(amount)) if amount is not None else None
+
+
+def format_satoshis(x: Union[float, int, Decimal, None]) -> Optional[str]:
+ """
+ input: satoshis as a Number
+ output: str formatted as bitcoin amount
+ """
+ if x is None:
+ return None
+ return util.format_satoshis_plain(x, is_max_allowed=False)
+
+
+class Command:
+ def __init__(self, func, name, s):
+ self.name = name
+ self.requires_network = 'n' in s # better name would be "requires daemon"
+ self.requires_wallet = 'w' in s
+ self.requires_password = 'p' in s
+ self.requires_lightning = 'l' in s
+ self.parse_docstring(func.__doc__)
+ varnames = func.__code__.co_varnames[1:func.__code__.co_argcount]
+ self.defaults = func.__defaults__
+ if self.defaults:
+ n = len(self.defaults)
+ self.params = list(varnames[:-n])
+ self.options = list(varnames[-n:])
+ else:
+ self.params = list(varnames)
+ 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, f"cmd: {self.name}: {varname} not in options {self.options}"
+ assert not ('wallet_path' in varnames and 'wallet' in varnames)
+ if self.requires_wallet:
+ assert 'wallet' in varnames
+
+ def parse_docstring(self, docstring):
+ docstring = docstring or ''
+ docstring = docstring.strip()
+ self.description = docstring
+ self.arg_descriptions = {}
+ self.arg_types = {}
+ for x in re.finditer(r'arg:(.*?):(.*?):(.*)$', docstring, flags=re.MULTILINE):
+ self.arg_descriptions[x.group(2)] = x.group(3)
+ self.arg_types[x.group(2)] = x.group(1)
+ self.description = self.description.replace(x.group(), '')
+ self.short_description = self.description.split('.')[0]
+
+
+def command(s):
+ def decorator(func):
+ if hasattr(func, '__wrapped__'):
+ # plugin command function
+ name = func.plugin_name + '_' + func.__name__
+ known_commands[name] = Command(func.__wrapped__, name, s)
+ else:
+ # regular command function
+ name = func.__name__
+ known_commands[name] = Command(func, name, s)
+
+ @wraps(func)
+ async def func_wrapper(*args, **kwargs):
+ cmd_runner = args[0] # type: Commands
+ cmd = known_commands[name] # type: Command
+ password = kwargs.get('password')
+ daemon = cmd_runner.daemon
+ if daemon:
+ if 'wallet_path' in cmd.options or cmd.requires_wallet:
+ kwargs['wallet_path'] = daemon.config.maybe_complete_wallet_path(kwargs.get('wallet_path'))
+ if 'wallet' in cmd.options:
+ wallet_path = kwargs.pop('wallet_path', None) # unit tests may set wallet and not wallet_path
+ wallet = kwargs.get('wallet', None) # run_offline_command sets both
+ if wallet is None and wallet_path is not None:
+ wallet = daemon.get_wallet(wallet_path)
+ if wallet is None:
+ raise UserFacingException('wallet not loaded')
+ kwargs['wallet'] = wallet
+ if cmd.requires_password and password is None and wallet and wallet.has_password():
+ password = wallet.get_unlocked_password()
+ if password:
+ kwargs['password'] = password
+ else:
+ raise UserFacingException('Password required. Unlock the wallet, or add a --password option to your command')
+ wallet = kwargs.get('wallet') # type: Optional[Abstract_Wallet]
+ if cmd.requires_wallet and not wallet:
+ raise UserFacingException('wallet not loaded')
+ if cmd.requires_password and wallet.has_password():
+ if password is None:
+ raise UserFacingException('Password required')
+ try:
+ wallet.check_password(password)
+ except InvalidPassword as e:
+ raise UserFacingException(str(e)) from None
+ if cmd.requires_lightning and (not wallet or not wallet.has_lightning()):
+ raise UserFacingException('Lightning not enabled in this wallet')
+ return await func(*args, **kwargs)
+ return func_wrapper
+ return decorator
+
+
+class Commands(Logger):
+
+ def __init__(self, *, config: 'SimpleConfig',
+ network: 'Network' = None,
+ daemon: 'Daemon' = None, callback=None):
+ Logger.__init__(self)
+ self.config = config
+ self.daemon = daemon
+ self.network = network
+ self._callback = callback
+
+ def _run(self, method, args, password_getter=None, **kwargs):
+ """This wrapper is called from unit tests and the Qt python console."""
+ cmd = known_commands[method]
+ password = kwargs.get('password', None)
+ wallet = kwargs.get('wallet', None)
+ if (cmd.requires_password and wallet and wallet.has_password()
+ and password is None):
+ password = password_getter()
+ if password is None:
+ return
+
+ f = getattr(self, method)
+ if cmd.requires_password:
+ kwargs['password'] = password
+
+ if 'wallet' in kwargs:
+ sig = inspect.signature(f)
+ if 'wallet' not in sig.parameters:
+ kwargs.pop('wallet')
+
+ coro = f(*args, **kwargs)
+ fut = asyncio.run_coroutine_threadsafe(coro, util.get_asyncio_loop())
+ result = fut.result()
+
+ if self._callback:
+ self._callback()
+ return result
+
+ @command('n')
+ async def getinfo(self):
+ """ network info """
+ net_params = self.network.get_parameters()
+ response = {
+ 'network': constants.net.NET_NAME,
+ 'path': self.network.config.path,
+ '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()),
+ 'connected': self.network.is_connected(),
+ 'auto_connect': net_params.auto_connect,
+ 'version': ELECTRUM_VERSION,
+ 'fee_estimates': self.network.fee_estimates.get_data()
+ }
+ return response
+
+ @command('n')
+ async def stop(self):
+ """Stop daemon"""
+ await self.daemon.stop()
+ return "Daemon stopped"
+
+ @command('n')
+ async def list_wallets(self):
+ """List wallets open in daemon"""
+ return [
+ {
+ 'path': w.storage.get_path(),
+ 'synchronized': w.is_up_to_date(),
+ 'unlocked': not w.has_password() or (w.get_unlocked_password() is not None),
+ }
+ for w in self.daemon.get_wallets().values()
+ ]
+
+ @command('n')
+ async def load_wallet(self, wallet_path=None, password=None):
+ """
+ Load the wallet in memory
+ """
+ wallet = self.daemon.load_wallet(wallet_path, password, upgrade=True)
+ if wallet is None:
+ raise UserFacingException('could not load wallet')
+ run_hook('load_wallet', wallet, None)
+ return wallet_path
+
+ @command('n')
+ async def close_wallet(self, wallet_path=None):
+ """Close wallet"""
+ return await self.daemon._stop_wallet(wallet_path)
+
+ @command('')
+ async def create(self, passphrase=None, password=None, encrypt_file=True, seed_type=None, wallet_path=None):
+ """Create a new wallet.
+ If you want to be prompted for an argument, type '?' or ':' (concealed)
+
+ arg:str:passphrase:Seed extension
+ arg:str:seed_type:The type of wallet to create, e.g. 'standard' or 'segwit'
+ arg:bool:encrypt_file:Whether the file on disk should be encrypted with the provided password
+ """
+ d = create_new_wallet(
+ path=wallet_path,
+ passphrase=passphrase,
+ password=password,
+ encrypt_file=encrypt_file,
+ seed_type=seed_type,
+ config=self.config)
+ return {
+ 'seed': d['seed'],
+ 'path': d['wallet'].storage.get_path(),
+ 'msg': d['msg'],
+ }
+
+ @command('')
+ async def restore(self, text, passphrase=None, password=None, encrypt_file=True, wallet_path=None):
+ """Restore a wallet from text. Text can be a seed phrase, a master
+ public key, a master private key, a list of bitcoin addresses
+ or bitcoin private keys.
+ If you want to be prompted for an argument, type '?' or ':' (concealed)
+
+ arg:str:text:seed phrase
+ arg:str:passphrase:Seed extension
+ arg:bool:encrypt_file:Whether the file on disk should be encrypted with the provided password
+ """
+ # TODO create a separate command that blocks until wallet is synced
+ d = restore_wallet_from_text(
+ text,
+ path=wallet_path,
+ passphrase=passphrase,
+ password=password,
+ encrypt_file=encrypt_file,
+ config=self.config)
+ return {
+ 'path': d['wallet'].storage.get_path(),
+ 'msg': d['msg'],
+ }
+
+ @command('wp')
+ async def password(self, password=None, new_password=None, encrypt_file=None, wallet: Abstract_Wallet = None):
+ """
+ Change wallet password.
+
+ arg:bool:encrypt_file:Whether the file on disk should be encrypted with the provided password (default=true)
+ arg:str:new_password:New Password
+ """
+ if wallet.storage.is_encrypted_with_hw_device() and new_password:
+ raise UserFacingException("Can't change the password of a wallet encrypted with a hw device.")
+ if encrypt_file is None:
+ if not password and new_password:
+ # currently no password, setting one now: we encrypt by default
+ encrypt_file = True
+ else:
+ encrypt_file = wallet.storage.is_encrypted()
+ wallet.update_password(password, new_password, encrypt_storage=encrypt_file)
+ wallet.save_db()
+ return {'password': wallet.has_password()}
+
+ @command('w')
+ async def get(self, key, wallet: Abstract_Wallet = None):
+ """
+ Return item from wallet storage
+
+ arg:str:key:storage key
+ """
+ return wallet.db.get(key)
+
+ @command('')
+ async def getconfig(self, key):
+ """Return the current value of a configuration variable.
+
+ arg:str:key:name of the configuration variable
+ """
+ if Plugins.is_plugin_enabler_config_key(key):
+ return self.config.get(key)
+ else:
+ cv = self.config.cv.from_key(key)
+ return cv.get()
+
+ @classmethod
+ def _setconfig_normalize_value(cls, key, value):
+ if key not in (SimpleConfig.RPC_USERNAME.key(), SimpleConfig.RPC_PASSWORD.key()):
+ value = json_decode(value)
+ # call literal_eval for backward compatibility (see #4225)
+ try:
+ value = ast.literal_eval(value)
+ except Exception:
+ pass
+ return value
+
+ def _setconfig(self, key, value):
+ value = self._setconfig_normalize_value(key, value)
+ if self.daemon and key in (
+ SimpleConfig.RPC_USERNAME.key(),
+ SimpleConfig.RPC_PASSWORD.key(),
+ SimpleConfig.RPC_HOST.key(),
+ SimpleConfig.RPC_PORT.key(),
+ SimpleConfig.RPC_SOCKET_TYPE.key(),
+ SimpleConfig.RPC_SOCKET_FILEPATH.key(),
+ ):
+ raise UserFacingException(
+ "error: RPC server settings cannot be changed for already running daemon. "
+ "Stop the daemon first, and run 'setconfig' in --offline mode. "
+ "\nFor example: '$ electrum -o setconfig rpcport 7777'."
+ )
+ if Plugins.is_plugin_enabler_config_key(key):
+ self.config.set_key(key, value)
+ else:
+ cv = self.config.cv.from_key(key)
+ cv.set(value)
+
+ @command('')
+ async def setconfig(self, key, value):
+ """
+ Set a configuration variable.
+
+ arg:str:key:name of the configuration variable
+ arg:str:value:value. may be a string or a Python expression.
+ """
+ self._setconfig(key, value)
+
+ @command('')
+ async def unsetconfig(self, key):
+ """
+ Clear a configuration variable.
+ The variable will be reset to its default value.
+
+ arg:str:key:name of the configuration variable
+ """
+ self._setconfig(key, None)
+
+ @command('')
+ async def listconfig(self):
+ """Returns the list of all configuration variables. """
+ return self.config.list_config_vars()
+
+ @command('')
+ async def helpconfig(self, key):
+ """Returns help about a configuration variable.
+
+ arg:str:key:name of the configuration variable
+ """
+ cv = self.config.cv.from_key(key)
+ short = cv.get_short_desc()
+ long = cv.get_long_desc()
+ if short and long:
+ return short + "\n---\n\n" + long
+ elif short or long:
+ return short or long
+ else:
+ return f"No description available for '{key}'"
+
+ @command('')
+ async def make_seed(self, nbits=None, language=None, seed_type=None):
+ """
+ Create a seed
+
+ arg:int:nbits:Number of bits of entropy
+ arg:str:seed_type:The type of seed to create, e.g. 'standard' or 'segwit'
+ arg:str:language:Default language for wordlist
+ """
+ s = Mnemonic(language).make_seed(seed_type=seed_type, num_bits=nbits)
+ return s
+
+ @command('n')
+ async def getaddresshistory(self, address):
+ """
+ Return the transaction history of any address. Note: This is a
+ walletless server query, results are not checked by SPV.
+
+ arg:str:address:Bitcoin address
+ """
+ sh = bitcoin.address_to_scripthash(address)
+ return await self.network.get_history_for_scripthash(sh)
+
+ @command('wp')
+ async def unlock(self, wallet: Abstract_Wallet = None, password=None):
+ """Unlock the wallet (store the password in memory)."""
+ wallet.unlock(password)
+
+ @command('w')
+ async def listunspent(self, wallet: Abstract_Wallet = None):
+ """List unspent outputs. Returns the list of unspent transaction
+ outputs in your wallet."""
+ coins = []
+ for txin in wallet.get_utxos():
+ d = txin.to_json()
+ v = d.pop("value_sats")
+ d["value"] = format_satoshis(v)
+ coins.append(d)
+ return coins
+
+ @command('n')
+ async def getaddressunspent(self, address):
+ """
+ Returns the UTXO list of any address. Note: This
+ is a walletless server query, results are not checked by SPV.
+
+ arg:str:address:Bitcoin address
+ """
+ sh = bitcoin.address_to_scripthash(address)
+ return await self.network.listunspent_for_scripthash(sh)
+
+ @command('')
+ async def serialize(self, jsontx):
+ """Create a signed raw transaction from a json tx template.
+
+ Example value for "jsontx" arg: {
+ "inputs": [
+ {"prevout_hash": "9d221a69ca3997cbeaf5624d723e7dc5f829b1023078c177d37bdae95f37c539", "prevout_n": 1,
+ "value_sats": 1000000, "privkey": "p2wpkh:cVDXzzQg6RoCTfiKpe8MBvmm5d5cJc6JLuFApsFDKwWa6F5TVHpD"}
+ ],
+ "outputs": [
+ {"address": "tb1q4s8z6g5jqzllkgt8a4har94wl8tg0k9m8kv5zd", "value_sats": 990000}
+ ]
+ }
+ arg:json:jsontx:Transaction in json
+ """
+ keypairs = {}
+ inputs = [] # type: List[PartialTxInput]
+ locktime = jsontx.get('locktime', 0)
+ for txin_idx, txin_dict in enumerate(jsontx.get('inputs')):
+ if txin_dict.get('prevout_hash') is not None and txin_dict.get('prevout_n') is not None:
+ prevout = TxOutpoint(txid=bfh(txin_dict['prevout_hash']), out_idx=int(txin_dict['prevout_n']))
+ elif txin_dict.get('output'):
+ prevout = TxOutpoint.from_str(txin_dict['output'])
+ else:
+ raise UserFacingException(f"missing prevout for txin {txin_idx}")
+ txin = PartialTxInput(prevout=prevout)
+ try:
+ txin._trusted_value_sats = int(txin_dict.get('value') or txin_dict['value_sats'])
+ except KeyError:
+ raise UserFacingException(f"missing 'value_sats' field for txin {txin_idx}")
+ 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)
+ pubkey = ecc.ECPrivkey(privkey).get_public_key_bytes(compressed=compressed)
+ keypairs[pubkey] = privkey
+ desc = descriptor.get_singlesig_descriptor_from_legacy_leaf(pubkey=pubkey.hex(), script_type=txin_type)
+ txin.script_descriptor = desc
+ inputs.append(txin)
+
+ outputs = [] # type: List[PartialTxOutput]
+ for txout_idx, txout_dict in enumerate(jsontx.get('outputs')):
+ try:
+ txout_addr = txout_dict['address']
+ except KeyError:
+ raise UserFacingException(f"missing 'address' field for txout {txout_idx}")
+ try:
+ txout_val = int(txout_dict.get('value') or txout_dict['value_sats'])
+ except KeyError:
+ raise UserFacingException(f"missing 'value_sats' field for txout {txout_idx}")
+ txout = PartialTxOutput.from_address_and_value(txout_addr, txout_val)
+ outputs.append(txout)
+
+ tx = PartialTransaction.from_io(inputs, outputs, locktime=locktime)
+ tx.sign(keypairs)
+ return tx.serialize()
+
+ @command('')
+ async def signtransaction_with_privkey(self, tx, privkey):
+ """Sign a transaction with private keys passed as parameter.
+
+ arg:tx:tx:Transaction to sign
+ arg:str:privkey:private key or list of private keys
+ """
+ tx = tx_from_any(tx)
+
+ txins_dict = defaultdict(list)
+ for txin in tx.inputs():
+ txins_dict[txin.address].append(txin)
+
+ if not isinstance(privkey, list):
+ privkey = [privkey]
+
+ for priv in privkey:
+ txin_type, priv2, compressed = bitcoin.deserialize_privkey(priv)
+ pubkey = ecc.ECPrivkey(priv2).get_public_key_bytes(compressed=compressed)
+ desc = descriptor.get_singlesig_descriptor_from_legacy_leaf(pubkey=pubkey.hex(), script_type=txin_type)
+ address = desc.expand().address()
+ if address in txins_dict.keys():
+ for txin in txins_dict[address]:
+ txin.script_descriptor = desc
+ tx.sign({pubkey: priv2})
+
+ return tx.serialize()
+
+ @command('wp')
+ async def signtransaction(self, tx, password=None, wallet: Abstract_Wallet = None, ignore_warnings: bool=False):
+ """
+ Sign a transaction with the current wallet.
+
+ arg:tx:tx:transaction
+ arg:bool:ignore_warnings:ignore warnings
+ """
+ tx = tx_from_any(tx)
+ wallet.sign_transaction(tx, password, ignore_warnings=ignore_warnings)
+ return tx.serialize()
+
+ @command('')
+ async def deserialize(self, tx):
+ """
+ Deserialize a transaction
+
+ arg:str:tx:Serialized transaction
+ """
+ tx = tx_from_any(tx)
+ return tx.to_json()
+
+ @command('n')
+ async def broadcast(self, tx):
+ """
+ Broadcast a transaction to the network.
+
+ arg:str:tx:Serialized transaction (must be hexadecimal)
+ """
+ tx = Transaction(tx)
+ await self.network.broadcast_transaction(tx)
+ return tx.txid()
+
+ @command('')
+ async def createmultisig(self, num, pubkeys):
+ """
+ Create multisig 'n of m' address
+
+ arg:int:num:Number of cosigners required
+ arg:json:pubkeys:List of public keys
+ """
+ assert isinstance(pubkeys, list), (type(num), type(pubkeys))
+ redeem_script = multisig_script(pubkeys, num)
+ address = bitcoin.hash160_to_p2sh(hash_160(redeem_script))
+ return {'address': address, 'redeemScript': redeem_script.hex()}
+
+ @command('w')
+ async def freeze(self, address: str, wallet: Abstract_Wallet = None):
+ """
+ Freeze address. Freeze the funds at one of your wallet\'s addresses
+
+ arg:str:address:Bitcoin address
+ """
+ return wallet.set_frozen_state_of_addresses([address], True)
+
+ @command('w')
+ async def unfreeze(self, address: str, wallet: Abstract_Wallet = None):
+ """
+ Unfreeze address. Unfreeze the funds at one of your wallet\'s address
+
+ arg:str:address:Bitcoin address
+ """
+ return wallet.set_frozen_state_of_addresses([address], False)
+
+ @command('w')
+ async def freeze_utxo(self, coin: str, wallet: Abstract_Wallet = None):
+ """
+ Freeze a UTXO so that the wallet will not spend it.
+
+ arg:str:coin:outpoint, in the format
+ """
+ wallet.set_frozen_state_of_coins([coin], True)
+ return True
+
+ @command('w')
+ async def unfreeze_utxo(self, coin: str, wallet: Abstract_Wallet = None):
+ """Unfreeze a UTXO so that the wallet might spend it.
+
+ arg:str:coin:outpoint
+ """
+ wallet.set_frozen_state_of_coins([coin], False)
+ return True
+
+ @command('wp')
+ async def getprivatekeys(self, address, password=None, wallet: Abstract_Wallet = None):
+ """
+ Get private keys of addresses. You may pass a single wallet address, or a list of wallet addresses.
+
+ arg:str:address:Bitcoin address
+ """
+ if isinstance(address, str):
+ address = address.strip()
+ if is_address(address):
+ return wallet.export_private_key(address, password)
+ 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).
+
+ arg:str:path:Derivation 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
+
+ arg:str:address:Bitcoin address
+ """
+ return wallet.is_mine(address)
+
+ @command('')
+ async def dumpprivkeys(self):
+ """Deprecated."""
+ return "This command is deprecated. Use a pipe instead: 'electrum listaddresses | electrum getprivatekeys - '"
+
+ @command('')
+ async def validateaddress(self, address):
+ """Check that an address is valid.
+
+ arg:str:address:Bitcoin address
+ """
+ return is_address(address)
+
+ @command('w')
+ async def getpubkeys(self, address, wallet: Abstract_Wallet = None):
+ """
+ Return the public keys for a wallet address.
+
+ arg:str:address:Bitcoin address
+ """
+ return wallet.get_public_keys(address)
+
+ @command('w')
+ async def getbalance(self, wallet: Abstract_Wallet = None):
+ """Return the balance of your wallet. """
+ c, u, x = wallet.get_balance()
+ l = wallet.lnworker.get_balance() if wallet.lnworker else None
+ out = {"confirmed": format_satoshis(c)}
+ if u:
+ out["unconfirmed"] = format_satoshis(u)
+ if x:
+ out["unmatured"] = format_satoshis(x)
+ if l:
+ out["lightning"] = format_satoshis(l)
+ return out
+
+ @command('n')
+ async def getaddressbalance(self, address):
+ """
+ Return the balance of any address. Note: This is a walletless
+ server query, results are not checked by SPV.
+
+ arg:str:address:Bitcoin address
+ """
+ sh = bitcoin.address_to_scripthash(address)
+ out = await self.network.get_balance_for_scripthash(sh)
+ out["confirmed"] = format_satoshis(out["confirmed"])
+ out["unconfirmed"] = format_satoshis(out["unconfirmed"])
+ return out
+
+ @command('n')
+ async def getmerkle(self, txid, height):
+ """Get Merkle branch of a transaction included in a block. Electrum
+ uses this to verify transactions (Simple Payment Verification).
+
+ arg:txid:txid:Transaction ID
+ arg:int:height:Block height
+ """
+ return await self.network.get_merkle_for_transaction(txid, int(height))
+
+ @command('n')
+ async def getservers(self):
+ """Return the list of known servers (candidates for connecting)."""
+ return self.network.get_servers()
+
+ @command('')
+ async def version(self):
+ """Return the version of Electrum."""
+ return ELECTRUM_VERSION
+
+ @command('')
+ async def version_info(self):
+ """Return information about dependencies, such as their version and path."""
+ ret = {
+ "electrum.version": ELECTRUM_VERSION,
+ "electrum.path": os.path.dirname(os.path.realpath(__file__)),
+ "python.version": sys.version,
+ "python.path": sys.executable,
+ }
+ # add currently running GUI
+ if self.daemon and self.daemon.gui_object:
+ ret.update(self.daemon.gui_object.version_info())
+ # always add Qt GUI, so we get info even when running this from CLI
+ try:
+ from .gui.qt import ElectrumGui as QtElectrumGui
+ ret.update(QtElectrumGui.version_info())
+ except GuiImportError:
+ pass
+ # Add shared libs (.so/.dll), and non-pure-python dependencies.
+ # Such deps can be installed in various ways - often via the Linux distro's pkg manager,
+ # instead of using pip, hence it is useful to list them for debugging.
+ from electrum_ecc import ecc_fast
+ ret.update(ecc_fast.version_info())
+ from . import qrscanner
+ ret.update(qrscanner.version_info())
+ ret.update(DeviceMgr.version_info())
+ ret.update(crypto.version_info())
+ # add some special cases
+ import aiohttp
+ ret["aiohttp.version"] = aiohttp.__version__
+ import aiorpcx
+ ret["aiorpcx.version"] = aiorpcx._version_str
+ import certifi
+ ret["certifi.version"] = certifi.__version__
+ import dns
+ ret["dnspython.version"] = dns.__version__
+ import ssl
+ ret["openssl.version"] = ssl.OPENSSL_VERSION
+
+ return ret
+
+ @command('w')
+ async def getmpk(self, wallet: Abstract_Wallet = None):
+ """Get master public key. Return your wallet\'s master public key"""
+ return wallet.get_master_public_key()
+
+ @command('wp')
+ async def getmasterprivate(self, password=None, wallet: Abstract_Wallet = None):
+ """Get master private key. Return your wallet\'s master private key"""
+ return str(wallet.keystore.get_master_private_key(password))
+
+ @command('')
+ async def convert_xkey(self, xkey, xtype):
+ """Convert xtype of a master key. e.g. xpub -> ypub
+
+ arg:str:xkey:the key
+ arg:str:xtype:the type, eg 'xpub'
+ """
+ try:
+ node = BIP32Node.from_xkey(xkey)
+ except Exception:
+ raise UserFacingException('xkey should be a master public/private key')
+ return node._replace(xtype=xtype).to_xkey()
+
+ @command('wp')
+ async def getseed(self, password=None, wallet: Abstract_Wallet = None):
+ """Get seed phrase. Print the generation seed of your wallet."""
+ s = wallet.get_seed(password)
+ return s
+
+ @command('wp')
+ async def importprivkey(self, privkey, password=None, wallet: Abstract_Wallet = None):
+ """Import a private key or a list of private keys.
+
+ arg:str:privkey:Private key. Type \'?\' to get a prompt.
+ """
+ if not wallet.can_import_privkey():
+ return "Error: This type of wallet cannot import private keys. Try to create a new wallet with that key."
+ assert isinstance(wallet, Imported_Wallet)
+ keys = privkey.split()
+ if not keys:
+ return "Error: no keys given"
+ elif len(keys) == 1:
+ try:
+ addr = wallet.import_private_key(keys[0], password)
+ out = "Keypair imported: " + addr
+ except Exception as e:
+ out = "Error: " + repr(e)
+ return out
+ else:
+ good_inputs, bad_inputs = wallet.import_private_keys(keys, password)
+ return {
+ "good_keys": len(good_inputs),
+ "bad_keys": len(bad_inputs),
+ }
+
+ async def _resolver(self, x, wallet: Abstract_Wallet):
+ if x is None:
+ return None
+ out = await wallet.contacts.resolve(x)
+ return out['address']
+
+ @command('n')
+ async def sweep(self, privkey, destination, fee=None, feerate=None, imax=100):
+ """
+ Sweep private keys. Returns a transaction that spends UTXOs from
+ privkey to a destination address. The transaction will not be broadcast.
+
+ arg:str:privkey:Private key. Type \'?\' to get a prompt.
+ arg:str:destination:Bitcoin address, contact or alias
+ arg:decimal:fee:Transaction fee (absolute, in BTC)
+ arg:decimal:feerate:Transaction fee rate (in sat/vbyte)
+ arg:int:imax:Maximum number of inputs
+ """
+ from .wallet import sweep
+ fee_policy = self._get_fee_policy(fee, feerate)
+ privkeys = privkey.split()
+ #dest = self._resolver(destination)
+ tx = await sweep(
+ privkeys,
+ network=self.network,
+ to_address=destination,
+ fee_policy=fee_policy,
+ imax=imax,
+ )
+ return tx.serialize() if tx else None
+
+ @command('wp')
+ async def signmessage(self, address, message, password=None, wallet: Abstract_Wallet = None):
+ """Sign a message with a key. Use quotes if your message contains
+ whitespaces
+
+ arg:str:address:Bitcoin address
+ arg:str:message:Clear text message. Use quotes if it contains spaces.
+ """
+ sig = wallet.sign_message(address, message, password)
+ return base64.b64encode(sig).decode('ascii')
+
+ @command('')
+ async def verifymessage(self, address, signature, message):
+ """Verify a signature.
+
+ arg:str:address:Bitcoin address
+ arg:str:message:Clear text message. Use quotes if it contains spaces.
+ arg:str:signature:The signature, base64-encoded.
+ """
+ try:
+ sig = base64.b64decode(signature, validate=True)
+ except binascii.Error:
+ return False
+ message = util.to_bytes(message)
+ return bitcoin.verify_usermessage_with_address(address, sig, message)
+
+ def _get_fee_policy(self, fee: str, feerate: str):
+ if fee is not None and feerate is not None:
+ raise Exception('Cannot set both fee and feerate')
+ if fee is not None:
+ fee_sats = satoshis(fee)
+ fee_policy = FeePolicy(f'fixed:{fee_sats}')
+ elif feerate is not None:
+ sat_per_kvbyte = int(1000 * to_decimal(feerate))
+ fee_policy = FeePolicy(f'feerate:{sat_per_kvbyte}')
+ else:
+ fee_policy = FeePolicy(self.config.FEE_POLICY)
+ return fee_policy
+
+ @command('wp')
+ async def payto(self, destination, amount, fee=None, feerate=None, from_addr=None, from_coins=None, change_addr=None,
+ unsigned=False, rbf=True, password=None, locktime=None, addtransaction=False, wallet: Abstract_Wallet = None):
+ """Create an on-chain transaction.
+
+ arg:str:destination:Bitcoin address, contact or alias
+ arg:decimal_or_max:amount:Amount to be sent (in BTC). Type '!' to send the maximum available.
+ arg:decimal:fee:Transaction fee (absolute, in BTC)
+ arg:decimal:feerate:Transaction fee rate (in sat/vbyte)
+ arg:str:from_addr:Source address (must be a wallet address; use sweep to spend from non-wallet address)
+ arg:str:change_addr:Change address. Default is a spare address, or the source address if it's not in the wallet
+ arg:bool:rbf:Whether to signal opt-in Replace-By-Fee in the transaction (true/false)
+ arg:bool:addtransaction:Whether transaction is to be used for broadcasting afterwards. Adds transaction to the wallet
+ arg:int:locktime:Set locktime block number
+ arg:bool:unsigned:Do not sign transaction
+ arg:json:from_coins:Source coins (must be in wallet; use sweep to spend from non-wallet address)
+ """
+ return await self.paytomany(
+ outputs=[(destination, amount),],
+ fee=fee,
+ feerate=feerate,
+ from_addr=from_addr,
+ from_coins=from_coins,
+ change_addr=change_addr,
+ unsigned=unsigned,
+ rbf=rbf,
+ password=password,
+ locktime=locktime,
+ addtransaction=addtransaction,
+ wallet=wallet,
+ )
+
+ @command('wp')
+ async def paytomany(self, outputs, fee=None, feerate=None, from_addr=None, from_coins=None, change_addr=None,
+ unsigned=False, rbf=True, password=None, locktime=None, addtransaction=False, wallet: Abstract_Wallet = None):
+ """Create a multi-output transaction.
+
+ arg:json:outputs:json list of ["address", "amount in BTC"]
+ arg:bool:rbf:Whether to signal opt-in Replace-By-Fee in the transaction (true/false)
+ arg:decimal:fee:Transaction fee (absolute, in BTC)
+ arg:decimal:feerate:Transaction fee rate (in sat/vbyte)
+ arg:str:from_addr:Source address (must be a wallet address; use sweep to spend from non-wallet address)
+ arg:str:change_addr:Change address. Default is a spare address, or the source address if it's not in the wallet
+ arg:bool:addtransaction:Whether transaction is to be used for broadcasting afterwards. Adds transaction to the wallet
+ arg:int:locktime:Set locktime block number
+ arg:bool:unsigned:Do not sign transaction
+ arg:json:from_coins:Source coins (must be in wallet; use sweep to spend from non-wallet address)
+ """
+ fee_policy = self._get_fee_policy(fee, feerate)
+ domain_addr = from_addr.split(',') if from_addr else None
+ domain_coins = from_coins.split(',') if from_coins else None
+ change_addr = await self._resolver(change_addr, wallet)
+ if domain_addr is not None:
+ resolvers = [self._resolver(addr, wallet) for addr in domain_addr]
+ domain_addr = await asyncio.gather(*resolvers)
+ final_outputs = []
+ for address, amount in outputs:
+ address = await self._resolver(address, wallet)
+ amount_sat = satoshis_or_max(amount)
+ final_outputs.append(PartialTxOutput.from_address_and_value(address, amount_sat))
+ 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)]
+ tx = wallet.make_unsigned_transaction(
+ outputs=final_outputs,
+ fee_policy=fee_policy,
+ change_addr=change_addr,
+ coins=coins,
+ rbf=rbf,
+ locktime=locktime,
+ )
+ if not unsigned:
+ wallet.sign_transaction(tx, password)
+ result = tx.serialize()
+ if addtransaction:
+ await self.addtransaction(result, wallet=wallet)
+ return result
+
+ def get_year_timestamps(self, year: int) -> dict[str, Any]:
+ kwargs = {}
+ if year:
+ start_date = datetime.datetime(year, 1, 1)
+ end_date = datetime.datetime(year+1, 1, 1)
+ kwargs['from_timestamp'] = time.mktime(start_date.timetuple())
+ kwargs['to_timestamp'] = time.mktime(end_date.timetuple())
+ return kwargs
+
+ @command('w')
+ async def onchain_capital_gains(self, year=None, wallet: Abstract_Wallet = None):
+ """
+ Capital gains, using utxo pricing.
+ This cannot be used with lightning.
+
+ arg:int:year:Show cap gains for a given year
+ """
+ kwargs = self.get_year_timestamps(year)
+ from .exchange_rate import FxThread
+ fx = self.daemon.fx if self.daemon else FxThread(config=self.config)
+ return json_normalize(wallet.get_onchain_capital_gains(fx, **kwargs))
+
+ @command('wp')
+ async def bumpfee(self, tx, new_fee_rate, from_coins=None, decrease_payment=False, password=None, unsigned=False, wallet: Abstract_Wallet = None):
+ """
+ Bump the fee for an unconfirmed transaction.
+ 'tx' can be either a raw hex tx or a txid. If txid, the corresponding tx must already be part of the wallet history.
+
+ arg:str:tx:Serialized transaction (hexadecimal)
+ arg:str:new_fee_rate: The Updated/Increased Transaction fee rate (in sats/vbyte)
+ arg:bool:decrease_payment:Whether payment amount will be decreased (true/false)
+ arg:bool:unsigned:Do not sign transaction
+ arg:json:from_coins:Coins that may be used to inncrease the fee (must be in wallet)
+ """
+ if is_hash256_str(tx): # txid
+ tx = wallet.db.get_transaction(tx)
+ if tx is None:
+ raise UserFacingException("Transaction not in wallet.")
+ else: # raw tx
+ try:
+ tx = Transaction(tx)
+ tx.deserialize()
+ except transaction.SerializationError as e:
+ raise UserFacingException(f"Failed to deserialize transaction: {e}") from e
+ domain_coins = from_coins.split(',') if from_coins else None
+ coins = wallet.get_spendable_coins(None)
+ if domain_coins is not None:
+ coins = [coin for coin in coins if (coin.prevout.to_str() in domain_coins)]
+ tx.add_info_from_wallet(wallet)
+ await tx.add_info_from_network(self.network)
+ new_tx = wallet.bump_fee(
+ tx=tx,
+ coins=coins,
+ strategy=BumpFeeStrategy.DECREASE_PAYMENT if decrease_payment else BumpFeeStrategy.PRESERVE_PAYMENT,
+ new_fee_rate=new_fee_rate)
+ if not unsigned:
+ wallet.sign_transaction(new_tx, password)
+ return new_tx.serialize()
+
+ @command('w')
+ async def onchain_history(
+ self, show_fiat=False, year=None, show_addresses=False,
+ from_height=None, to_height=None,
+ wallet: Abstract_Wallet = None,
+ ):
+ """Wallet onchain history. Returns the transaction history of your wallet.
+
+ arg:bool:show_addresses:Show input and output addresses
+ arg:bool:show_fiat:Show fiat value of transactions
+ arg:int:year:Show history for a given year
+ arg:int:from_height:Only show transactions that confirmed after(inclusive) given block height
+ arg:int:to_height:Only show transactions that confirmed before(exclusive) given block height
+ """
+ # trigger lnwatcher callbacks for their side effects: setting labels and accounting_addresses
+ if not self.network and wallet.lnworker:
+ await wallet.lnworker.lnwatcher.trigger_callbacks(requires_synchronizer=False)
+
+ kwargs = self.get_year_timestamps(year)
+ kwargs['from_height'] = from_height
+ kwargs['to_height'] = to_height
+ onchain_history = wallet.get_onchain_history(**kwargs)
+ out = [x.to_dict() for x in onchain_history.values()]
+ if show_fiat:
+ from .exchange_rate import FxThread
+ fx = self.daemon.fx if self.daemon else FxThread(config=self.config)
+ else:
+ fx = None
+ for item in out:
+ if show_addresses:
+ tx = wallet.db.get_transaction(item['txid'])
+ item['inputs'] = list(map(lambda x: x.to_json(), tx.inputs()))
+ item['outputs'] = list(map(lambda x: {'address': x.get_ui_address_str(), 'value_sat': x.value},
+ tx.outputs()))
+ if fx:
+ fiat_fields = wallet.get_tx_item_fiat(tx_hash=item['txid'], amount_sat=item['amount_sat'], fx=fx, tx_fee=item['fee_sat'])
+ item.update(fiat_fields)
+ return json_normalize(out)
+
+ @command('wl')
+ async def lightning_history(self, wallet: Abstract_Wallet = None):
+ """ lightning history. """
+ lightning_history = wallet.lnworker.get_lightning_history() if wallet.lnworker else {}
+ sorted_hist= sorted(lightning_history.values(), key=lambda x: x.timestamp)
+ return json_normalize([x.to_dict() for x in sorted_hist])
+
+ @command('w')
+ async def setlabel(self, key, label, wallet: Abstract_Wallet = None):
+ """
+ Assign a label to an item. Item may be a bitcoin address or a
+ transaction ID
+
+ arg:str:key:Key
+ arg:str:label:Label
+ """
+ wallet.set_label(key, label)
+
+ @command('w')
+ async def listcontacts(self, wallet: Abstract_Wallet = None):
+ """Show your list of contacts"""
+ return wallet.contacts
+
+ @command('w')
+ async def getopenalias(self, key, wallet: Abstract_Wallet = None):
+ """
+ Retrieve alias. Lookup in your list of contacts, and for an OpenAlias DNS record.
+
+ arg:str:key:the alias to be retrieved
+ """
+ d = await wallet.contacts.resolve(key)
+ if d.get("type") == "openalias":
+ # we always validate DNSSEC now
+ d["validated"] = True
+ return d
+
+ @command('w')
+ async def searchcontacts(self, query, wallet: Abstract_Wallet = None):
+ """
+ Search through your wallet contacts, return matching entries.
+
+ arg:str:query:Search query
+ """
+ results = {}
+ for key, value in wallet.contacts.items():
+ if query.lower() in key.lower():
+ results[key] = value
+ return results
+
+ @command('w')
+ async def listaddresses(self, receiving=False, change=False, labels=False, frozen=False, unused=False, funded=False, balance=False, wallet: Abstract_Wallet = None):
+ """List wallet addresses. Returns the list of all addresses in your wallet. Use optional arguments to filter the results.
+
+ arg:bool:receiving:Show only receiving addresses
+ arg:bool:change:Show only change addresses
+ arg:bool:frozen:Show only frozen addresses
+ arg:bool:unused:Show only unused addresses
+ arg:bool:funded:Show only funded addresses
+ arg:bool:balance:Show the balances of listed addresses
+ arg:bool:labels:Show the labels of listed addresses
+ """
+ out = []
+ for addr in wallet.get_addresses():
+ if frozen and not wallet.is_frozen_address(addr):
+ continue
+ if receiving and wallet.is_change(addr):
+ continue
+ if change and not wallet.is_change(addr):
+ continue
+ if unused and wallet.adb.is_used(addr):
+ continue
+ if funded and wallet.adb.is_empty(addr):
+ continue
+ item = addr
+ if labels or balance:
+ item = (item,)
+ if balance:
+ item += (format_satoshis(sum(wallet.get_addr_balance(addr))),)
+ if labels:
+ item += (repr(wallet.get_label_for_address(addr)),)
+ out.append(item)
+ return out
+
+ @command('n')
+ async def gettransaction(self, txid, wallet: Abstract_Wallet = None):
+ """Retrieve a transaction.
+
+ arg:txid:txid:Transaction ID
+ """
+ tx = None
+ if wallet:
+ tx = wallet.db.get_transaction(txid)
+ if tx is None:
+ raw = await self.network.get_transaction(txid)
+ if raw:
+ tx = Transaction(raw)
+ else:
+ raise UserFacingException("Unknown transaction")
+ if tx.txid() != txid:
+ raise UserFacingException("Mismatching txid")
+ return tx.serialize()
+
+ @command('')
+ async def encrypt(self, pubkey, message) -> str:
+ """
+ Encrypt a message with a public key. Use quotes if the message contains whitespaces.
+
+ arg:str:pubkey:Public key
+ arg:str:message:Clear text message. Use quotes if it contains spaces.
+ """
+ if not is_hex_str(pubkey):
+ raise UserFacingException(f"pubkey must be a hex string instead of {repr(pubkey)}")
+ try:
+ message = to_bytes(message)
+ except TypeError:
+ raise UserFacingException(f"message must be a string-like object instead of {repr(message)}")
+ public_key = ecc.ECPubkey(bfh(pubkey))
+ encrypted = crypto.ecies_encrypt_message(public_key, message)
+ return encrypted.decode('utf-8')
+
+ @command('wp')
+ async def decrypt(self, pubkey, encrypted, password=None, wallet: Abstract_Wallet = None) -> str:
+ """Decrypt a message encrypted with a public key.
+
+ arg:str:encrypted:Encrypted message
+ arg:str:pubkey:Public key of one of your wallet addresses
+ """
+ if not is_hex_str(pubkey):
+ raise UserFacingException(f"pubkey must be a hex string instead of {repr(pubkey)}")
+ if not isinstance(encrypted, (str, bytes, bytearray)):
+ raise UserFacingException(f"encrypted must be a string-like object instead of {repr(encrypted)}")
+ decrypted = wallet.decrypt_message(pubkey, encrypted, password)
+ return decrypted.decode('utf-8')
+
+ @command('w')
+ async def get_request(self, request_id, wallet: Abstract_Wallet = None):
+ """Returns a payment request
+
+ arg:str:request_id:The request ID, as seen in list_requests or add_request
+ """
+ r = wallet.get_request(request_id)
+ if not r:
+ raise UserFacingException("Request not found")
+ return wallet.export_request(r)
+
+ @command('w')
+ async def get_invoice(self, invoice_id, wallet: Abstract_Wallet = None):
+ """
+ Returns an invoice (request for outgoing payment)
+
+ arg:str:invoice_id:The invoice ID, as seen in list_invoices
+ """
+ r = wallet.get_invoice(invoice_id)
+ if not r:
+ raise UserFacingException("Request not found")
+ return wallet.export_invoice(r)
+
+ def _filter_invoices(self, _list, wallet, pending, expired, paid):
+ if pending:
+ f = PR_UNPAID
+ elif expired:
+ f = PR_EXPIRED
+ elif paid:
+ f = PR_PAID
+ else:
+ f = None
+ if f is not None:
+ _list = [x for x in _list if f == wallet.get_invoice_status(x)]
+ return _list
+
+ @command('w')
+ async def list_requests(self, pending=False, expired=False, paid=False, wallet: Abstract_Wallet = None):
+ """
+ Returns the list of incoming payment requests saved in the wallet.
+ arg:bool:paid:Show only paid requests
+ arg:bool:pending:Show only pending requests
+ arg:bool:expired:Show only expired requests
+ """
+ l = wallet.get_sorted_requests()
+ l = self._filter_invoices(l, wallet, pending, expired, paid)
+ return [wallet.export_request(x) for x in l]
+
+ @command('w')
+ async def list_invoices(self, pending=False, expired=False, paid=False, wallet: Abstract_Wallet = None):
+ """
+ Returns the list of invoices (requests for outgoing payments) saved in the wallet.
+ arg:bool:paid:Show only paid invoices
+ arg:bool:pending:Show only pending invoices
+ arg:bool:expired:Show only expired invoices
+ """
+ l = wallet.get_invoices()
+ l = self._filter_invoices(l, wallet, pending, expired, paid)
+ return [wallet.export_invoice(x) for x in l]
+
+ @command('w')
+ async def createnewaddress(self, wallet: Abstract_Wallet = None):
+ """Create a new receiving address, beyond the gap limit of the wallet"""
+ return wallet.create_new_address(False)
+
+ @command('w')
+ async def changegaplimit(self, new_limit, iknowwhatimdoing=False, wallet: Abstract_Wallet = None):
+ """
+ Change the gap limit of the wallet.
+
+ arg:int:new_limit:new gap limit
+ arg:bool:iknowwhatimdoing:Acknowledge that I understand the full implications of what I am about to do
+ """
+ if not iknowwhatimdoing:
+ raise UserFacingException(
+ "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 UserFacingException("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 UserFacingException("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 getunusedaddress(self, wallet: Abstract_Wallet = None):
+ """Returns the first unused address of the wallet, or None if all addresses are used.
+ An address is considered as used if it has received a transaction, or if it is used in a payment request."""
+ return wallet.get_unused_address()
+
+ @command('w')
+ async def add_request(self, amount, memo='', expiry=3600, lightning=False, force=False, wallet: Abstract_Wallet = None):
+ """Create a payment request, using the first unused address of the wallet.
+
+ The address will be considered as used after this operation.
+ If no payment is received, the address will be considered as unused if the payment request is deleted from the wallet.
+
+ arg:decimal:amount:Requested amount (in btc)
+ arg:str:memo:Description of the request
+ arg:bool:force:Create new address beyond gap limit, if no more addresses are available.
+ arg:bool:lightning:Create lightning request.
+ arg:int:expiry:Time in seconds.
+ """
+ amount = satoshis(amount)
+ if not lightning:
+ addr = wallet.get_unused_address()
+ if addr is None:
+ if force:
+ addr = wallet.create_new_address(False)
+ else:
+ return False
+ else:
+ addr = None
+ expiry = int(expiry) if expiry else None
+ key = wallet.create_request(amount, memo, expiry, addr)
+ req = wallet.get_request(key)
+ return wallet.export_request(req)
+
+ @command('wnl')
+ async def add_hold_invoice(
+ self,
+ payment_hash: str,
+ amount: Optional[Decimal] = None,
+ memo: str = "",
+ expiry: int = 3600,
+ min_final_cltv_expiry_delta: int = MIN_FINAL_CLTV_DELTA_ACCEPTED * 2,
+ wallet: Abstract_Wallet = None
+ ) -> dict:
+ """
+ Create a lightning hold invoice for the given payment hash. Hold invoices have to get settled manually later.
+ HTLCs will get failed automatically if block_height + 144 > htlc.cltv_abs, if the intention is to
+ settle them as late as possible a safety margin of some blocks should be used to prevent them
+ from getting failed accidentally.
+
+ arg:str:payment_hash:Hex encoded payment hash to be used for the invoice
+ arg:decimal:amount:Optional requested amount (in btc)
+ arg:str:memo:Optional description of the invoice
+ arg:int:expiry:Optional expiry in seconds (default: 3600s)
+ arg:int:min_final_cltv_expiry_delta:Optional min final cltv expiry delta (default: 294 blocks)
+ """
+ assert len(payment_hash) == 64, f"Invalid payment hash length: {len(payment_hash)} != 64"
+ assert not wallet.lnworker.get_payment_info(bfh(payment_hash), direction=RECEIVED), "Payment hash already used!"
+ assert payment_hash not in wallet.lnworker.dont_expire_htlcs, "Payment hash already used!"
+ assert wallet.lnworker.get_preimage(bfh(payment_hash)) is None, "Already got a preimage for this payment hash!"
+ assert MIN_FINAL_CLTV_DELTA_ACCEPTED < min_final_cltv_expiry_delta < 576, "Use a sane min_final_cltv_expiry_delta value"
+ amount = amount if amount and satoshis(amount) > 0 else None # make amount either >0 or None
+ inbound_capacity = wallet.lnworker.num_sats_can_receive()
+ assert inbound_capacity > satoshis(amount or 0), \
+ f"Not enough inbound capacity [{inbound_capacity} sat] to receive this payment"
+
+ wallet.lnworker.add_payment_info_for_hold_invoice(
+ bfh(payment_hash),
+ lightning_amount_sat=satoshis(amount) if amount else None,
+ min_final_cltv_delta=min_final_cltv_expiry_delta,
+ exp_delay=expiry,
+ )
+ info = wallet.lnworker.get_payment_info(bfh(payment_hash), direction=RECEIVED)
+ lnaddr, invoice = wallet.lnworker.get_bolt11_invoice(
+ payment_info=info,
+ message=memo,
+ fallback_address=None
+ )
+ # this prevents incoming htlcs from getting expired while the preimage isn't set.
+ # If their blocks to expiry fall below MIN_FINAL_CLTV_DELTA_ACCEPTED they will get failed.
+ wallet.lnworker.dont_expire_htlcs[payment_hash] = MIN_FINAL_CLTV_DELTA_ACCEPTED
+ wallet.set_label(payment_hash, memo)
+ result = {
+ "invoice": invoice
+ }
+ return result
+
+ @command('wnl')
+ async def settle_hold_invoice(self, preimage: str, wallet: Abstract_Wallet = None) -> dict:
+ """
+ Settles lightning hold invoice with the given preimage.
+ Doesn't block until actual settlement of the HTLCs.
+
+ arg:str:preimage:Hex encoded preimage of the invoice to be settled
+ """
+ assert len(preimage) == 64, f"Invalid payment_hash length: {len(preimage)} != 64"
+ payment_hash: str = crypto.sha256(bfh(preimage)).hex()
+ assert payment_hash not in wallet.lnworker._preimages, f"Invoice {payment_hash=} already settled"
+ info = wallet.lnworker.get_payment_info(bfh(payment_hash), direction=RECEIVED)
+ assert info, f"Couldn't find lightning invoice for {payment_hash=}"
+ assert payment_hash in wallet.lnworker.dont_expire_htlcs, f"Invoice {payment_hash=} not a hold invoice?"
+ assert wallet.lnworker.is_complete_mpp(bfh(payment_hash)), \
+ f"MPP incomplete, cannot settle hold invoice {payment_hash} yet"
+ assert (wallet.lnworker.get_payment_mpp_amount_msat(bfh(payment_hash)) or 0) >= (info.amount_msat or 0)
+ wallet.lnworker.save_preimage(bfh(payment_hash), bfh(preimage))
+ util.trigger_callback('wallet_updated', wallet)
+ result = {
+ "settled": payment_hash
+ }
+ return result
+
+ @command('wnl')
+ async def cancel_hold_invoice(self, payment_hash: str, wallet: Abstract_Wallet = None) -> dict:
+ """
+ Cancels lightning hold invoice 'payment_hash'.
+
+ arg:str:payment_hash:Payment hash in hex of the hold invoice
+ """
+ assert wallet.lnworker.get_payment_info(bfh(payment_hash), direction=RECEIVED), \
+ f"Couldn't find lightning invoice for payment hash {payment_hash}"
+ assert payment_hash not in wallet.lnworker._preimages, "Cannot cancel anymore, preimage already given."
+ assert payment_hash in wallet.lnworker.dont_expire_htlcs, f"{payment_hash=} not a hold invoice?"
+ # set to PR_UNPAID so it can get deleted
+ wallet.lnworker.set_payment_status(bfh(payment_hash), PR_UNPAID, direction=RECEIVED)
+ wallet.lnworker.delete_payment_info(payment_hash, direction=RECEIVED)
+ wallet.set_label(payment_hash, None)
+ del wallet.lnworker.dont_expire_htlcs[payment_hash]
+ while wallet.lnworker.is_complete_mpp(bfh(payment_hash)):
+ # block until the htlcs got failed
+ await asyncio.sleep(0.1)
+ result = {
+ "cancelled": payment_hash
+ }
+ return result
+
+ @command('wnl')
+ async def check_hold_invoice(self, payment_hash: str, wallet: Abstract_Wallet = None) -> dict:
+ """
+ Checks the status of a lightning hold invoice 'payment_hash'.
+ Returns: {
+ "status": unpaid | paid | settled | unknown (cancelled or not found),
+ "received_amount_sat": currently received amount (pending htlcs or final after settling),
+ "invoice_amount_sat": Invoice amount, Optional (only if invoice is found),
+ "closest_htlc_expiry_height": Closest absolute expiry height of all received htlcs
+ (Note: HTLCs will get failed automatically if block_height + 144 > htlc_expiry_height)
+ }
+
+ arg:str:payment_hash:Payment hash in hex of the hold invoice
+ """
+ assert len(payment_hash) == 64, f"Invalid payment_hash length: {len(payment_hash)} != 64"
+ info: Optional['PaymentInfo'] = wallet.lnworker.get_payment_info(bfh(payment_hash), direction=RECEIVED)
+ is_complete_mpp: bool = wallet.lnworker.is_complete_mpp(bfh(payment_hash))
+ amount_sat = (wallet.lnworker.get_payment_mpp_amount_msat(bfh(payment_hash)) or 0) // 1000
+ result = {
+ "status": "unknown",
+ "received_amount_sat": amount_sat,
+ }
+ if info is None:
+ pass
+ elif not is_complete_mpp and not wallet.lnworker.get_preimage_hex(payment_hash):
+ # is_complete_mpp is False for settled payments
+ result["status"] = "unpaid"
+ elif is_complete_mpp and payment_hash in wallet.lnworker.dont_expire_htlcs:
+ result["status"] = "paid"
+ payment_key: str = wallet.lnworker._get_payment_key(bfh(payment_hash)).hex()
+ htlc_status = wallet.lnworker.received_mpp_htlcs[payment_key]
+ result["closest_htlc_expiry_height"] = min(
+ mpp_htlc.htlc.cltv_abs for mpp_htlc in htlc_status.htlcs
+ )
+ elif wallet.lnworker.get_preimage_hex(payment_hash) is not None:
+ result["status"] = "settled"
+ plist = wallet.lnworker.get_payments(status='settled')[bfh(payment_hash)]
+ _dir, amount_msat, _fee, _ts = wallet.lnworker.get_payment_value(None, plist)
+ result["received_amount_sat"] = amount_msat // 1000
+ result['preimage'] = wallet.lnworker.get_preimage_hex(payment_hash)
+ if info is not None:
+ result["invoice_amount_sat"] = (info.amount_msat or 0) // 1000
+ return result
+
+ @command('wl')
+ async def export_lightning_preimage(self, payment_hash: str, wallet: 'Abstract_Wallet' = None) -> Optional[str]:
+ """
+ Returns the stored preimage of the given payment_hash if it is known.
+
+ note: Exporting a preimage does not require the wallet password (RPC access is enough).
+ We don't consider preimages as sensitive as private keys.
+
+ arg:str:payment_hash: Hash of the preimage
+ """
+ preimage = wallet.lnworker.get_preimage_hex(payment_hash)
+ assert preimage is None or crypto.sha256(bytes.fromhex(preimage)).hex() == payment_hash
+ return preimage
+
+ @command('w')
+ async def addtransaction(self, tx, wallet: Abstract_Wallet = None):
+ """
+ Add a transaction to the wallet history, without broadcasting it.
+
+ arg:tx:tx:Transaction, in hexadecimal format.
+ """
+ tx = Transaction(tx)
+ if not wallet.adb.add_transaction(tx):
+ return False
+ wallet.save_db()
+ return tx.txid()
+
+ @command('w')
+ async def delete_request(self, request_id, wallet: Abstract_Wallet = None):
+ """Remove an incoming payment request
+
+ arg:str:request_id:The request ID, as returned in list_invoices
+ """
+ return wallet.delete_request(request_id)
+
+ @command('w')
+ async def delete_invoice(self, invoice_id, wallet: Abstract_Wallet = None):
+ """Remove an outgoing payment invoice
+
+ arg:str:invoice_id:The invoice ID, as returned in list_invoices
+ """
+ return wallet.delete_invoice(invoice_id)
+
+ @command('w')
+ async def clear_requests(self, wallet: Abstract_Wallet = None):
+ """Remove all payment requests"""
+ wallet.clear_requests()
+ return True
+
+ @command('w')
+ async def clear_invoices(self, wallet: Abstract_Wallet = None):
+ """Remove all invoices"""
+ wallet.clear_invoices()
+ return True
+
+ @command('n')
+ 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.
+
+ arg:str:address:Bitcoin address
+ arg:str:URL:The callback URL
+ """
+ if not hasattr(self, "_notifier"):
+ self._notifier = Notifier(self.network)
+ if URL:
+ await self._notifier.start_watching_addr(address, URL)
+ else:
+ await self._notifier.stop_watching_addr(address)
+ return True
+
+ @command('wn')
+ async def is_synchronized(self, wallet: Abstract_Wallet = None):
+ """ return wallet synchronization status """
+ return wallet.is_up_to_date()
+
+ @command('wn')
+ async def wait_for_sync(self, wallet: Abstract_Wallet = None):
+ """Block until the wallet synchronization finishes."""
+ while True:
+ if wallet.is_up_to_date():
+ return True
+ await wallet.up_to_date_changed_event.wait()
+
+ @command('n')
+ async def getfeerate(self):
+ """
+ Return current fee estimate given network conditions (in sat/kvByte).
+ To change the fee policy, use 'getconfig/setconfig fee_policy'
+ """
+ fee_policy = FeePolicy(self.config.FEE_POLICY)
+ description = fee_policy.get_target_text()
+ feerate = fee_policy.fee_per_kb(self.network)
+ tooltip = fee_policy.get_estimate_text(self.network)
+ return {
+ 'policy': fee_policy.get_descriptor(),
+ 'description': description,
+ 'sat/kvB': feerate,
+ 'tooltip': tooltip,
+ }
+
+ @command('n')
+ async def test_inject_fee_etas(self, fee_est):
+ """
+ Inject fee estimates into the network object, as if they were coming from connected servers.
+ `setconfig 'test_disable_automatic_fee_eta_update' true` to prevent Network from overriding
+ the configured fees.
+ Useful on regtest.
+
+ arg:str:fee_est:dict of ETA-based fee estimates, encoded as str
+ """
+ if not isinstance(fee_est, dict):
+ fee_est = ast.literal_eval(fee_est)
+ assert isinstance(fee_est, dict), f"unexpected type for fee_est. got {repr(fee_est)}"
+ # populate missing high-block-number estimates using default relay fee.
+ # e.g. {"25": 2222} -> {"25": 2222, "144": 1000, "1008": 1000}
+ furthest_estimate = max(fee_est.keys()) if fee_est else 0
+ further_fee_est = {
+ eta_target: FEERATE_DEFAULT_RELAY for eta_target in FEE_ETA_TARGETS
+ if eta_target > furthest_estimate
+ }
+ fee_est.update(further_fee_est)
+ self.network.update_fee_estimates(fee_est=fee_est)
+
+ @command('w')
+ async def removelocaltx(self, txid, wallet: Abstract_Wallet = None):
+ """Remove a 'local' transaction from the wallet, and its dependent
+ transactions.
+
+ arg:txid:txid:Transaction ID
+ """
+ height = wallet.adb.get_tx_height(txid).height()
+ if height != TX_HEIGHT_LOCAL:
+ raise UserFacingException(
+ f'Only local transactions can be removed. '
+ f'This tx has height: {height} != {TX_HEIGHT_LOCAL}')
+ wallet.adb.remove_transaction(txid)
+ wallet.save_db()
+
+ @command('wn')
+ async def get_tx_status(self, txid, wallet: Abstract_Wallet = None):
+ """Returns some information regarding the tx. For now, only confirmations.
+ The transaction must be related to the wallet.
+
+ arg:txid:txid:Transaction ID
+ """
+ if not wallet.db.get_transaction(txid):
+ raise UserFacingException("Transaction not in wallet.")
+ return {
+ "confirmations": wallet.adb.get_tx_height(txid).conf,
+ }
+
+ @command('')
+ async def help(self):
+ """Show help about a command"""
+ # for the python console
+ return sorted(known_commands.keys())
+
+ # lightning network commands
+ @command('wnl')
+ async def add_peer(self, connection_string, timeout=20, gossip=False, wallet: Abstract_Wallet = None):
+ """
+ Connect to a lightning node
+
+ arg:str:connection_string:Lightning network node ID or network address
+ arg:bool:gossip:Apply command to your gossip node instead of wallet node
+ arg:int:timeout:Timeout in seconds (default=20)
+ """
+ lnworker = self.network.lngossip if gossip else wallet.lnworker
+ peer = await lnworker.lnpeermgr.add_peer(connection_string)
+ try:
+ await util.wait_for2(peer.initialized, timeout=LN_P2P_NETWORK_TIMEOUT)
+ except (CancelledError, Exception) as e:
+ # FIXME often simply CancelledError and real cause (e.g. timeout) remains hidden
+ raise UserFacingException(f"Connection failed: {repr(e)}")
+ return True
+
+ @command('wnl')
+ async def gossip_info(self, wallet: Abstract_Wallet = None):
+ """Display statistics about lightninig gossip"""
+ lngossip = self.network.lngossip
+ channel_db = lngossip.channel_db
+ forwarded = dict([(key.hex(), p._num_gossip_messages_forwarded) for key, p in wallet.lnworker.lnpeermgr.peers.items()]),
+ out = {
+ 'received': {
+ 'channel_announcements': lngossip._num_chan_ann,
+ 'channel_updates': lngossip._num_chan_upd,
+ 'channel_updates_good': lngossip._num_chan_upd_good,
+ 'node_announcements': lngossip._num_node_ann,
+ },
+ 'database': {
+ 'nodes': channel_db.num_nodes,
+ 'channels': channel_db.num_channels,
+ 'channel_policies': channel_db.num_policies,
+ },
+ 'forwarded': forwarded,
+ }
+ return out
+
+ @command('wnl')
+ async def list_peers(self, gossip=False, wallet: Abstract_Wallet = None):
+ """
+ List lightning peers of your node
+
+ arg:bool:gossip:Apply command to your gossip node instead of wallet node
+ """
+ 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.lnpeermgr.peers.values()]
+
+ @command('wpnl')
+ async def open_channel(self, connection_string, amount, push_amount=0, public=False, zeroconf=False, password=None, wallet: Abstract_Wallet = None):
+ """
+ Open a lightning channel with a peer
+
+ arg:str:connection_string:Lightning network node ID or network address
+ arg:decimal_or_max:amount:funding amount (in BTC)
+ arg:decimal:push_amount:Push initial amount (in BTC)
+ arg:bool:public:The channel will be announced
+ arg:bool:zeroconf:request zeroconf channel
+ """
+ if not wallet.can_have_lightning():
+ raise UserFacingException("This wallet cannot create new channels")
+ funding_sat = satoshis(amount)
+ push_sat = satoshis(push_amount)
+ peer = await wallet.lnworker.lnpeermgr.add_peer(connection_string)
+ chan, funding_tx = await wallet.lnworker.open_channel_with_peer(
+ peer, funding_sat,
+ push_sat=push_sat,
+ public=public,
+ zeroconf=zeroconf,
+ password=password)
+ return chan.funding_outpoint.to_str()
+
+ @command('')
+ async def decode_invoice(self, invoice: str):
+ """
+ Decode a lightning invoice
+
+ arg:str:invoice:Lightning invoice (bolt 11)
+ """
+ invoice = Invoice.from_bech32(invoice)
+ return invoice.to_debug_json()
+
+ @command('wnpl')
+ async def lnpay(
+ self,
+ invoice: str,
+ timeout: int = 120,
+ max_cltv: Optional[int] = None,
+ max_fee_msat: Optional[int] = None,
+ password=None,
+ wallet: Abstract_Wallet = None
+ ):
+ """
+ Pay a lightning invoice
+ Note: it is *not* safe to try paying the same invoice multiple times with a timeout.
+ It is only safe to retry paying the same invoice if there are no more pending HTLCs
+ with the same payment_hash. # FIXME should there even be a default timeout? just block forever.
+
+ arg:str:invoice:Lightning invoice (bolt 11)
+ arg:int:timeout:Timeout in seconds (default=120)
+ arg:int:max_cltv:Maximum total time lock for the route (default=4032+invoice_final_cltv_delta)
+ arg:int:max_fee_msat:Maximum absolute fee budget for the payment (if unset, the default is a percentage fee based on config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS)
+ """
+ # note: The "timeout" param works via black magic.
+ # The CLI-parser stores it in the config, and the argname matches config.cv.CLI_TIMEOUT.key().
+ # - it works when calling the CLI and there is also a daemon (online command)
+ # - FIXME it does NOT work when calling an offline command (-o)
+ # - FIXME it does NOT work when calling RPC directly (e.g. curl)
+ lnworker = wallet.lnworker
+ lnaddr = lnworker._check_bolt11_invoice(invoice) # also checks if amount is given
+ payment_hash = lnaddr.paymenthash
+ invoice_obj = Invoice.from_bech32(invoice)
+ assert not max_fee_msat or max_fee_msat < max(invoice_obj.amount_msat // 2, 1_000_000), \
+ f"{max_fee_msat=} > max(invoice amount msat / 2, 1_000_000)"
+ wallet.save_invoice(invoice_obj)
+ if max_cltv is not None:
+ # The cltv budget excludes the final cltv delta which is why it is deducted here
+ # so the whole used cltv is <= max_cltv
+ assert max_cltv <= NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, \
+ f"{max_cltv=} > {NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE=}"
+ max_cltv_remaining = max_cltv - lnaddr.get_min_final_cltv_delta()
+ assert max_cltv_remaining > 0, f"{max_cltv=} - {lnaddr.get_min_final_cltv_delta()=} < 1"
+ max_cltv = max_cltv_remaining
+ budget = PaymentFeeBudget.from_invoice_amount(
+ config=wallet.config,
+ invoice_amount_msat=invoice_obj.amount_msat,
+ max_cltv_delta=max_cltv,
+ max_fee_msat=max_fee_msat,
+ )
+ success, log = await lnworker.pay_invoice(invoice_obj, budget=budget)
+ 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('wl')
+ async def nodeid(self, wallet: Abstract_Wallet = None):
+ """Return the Lightning Node ID of a wallet"""
+ listen_addr = self.config.LIGHTNING_LISTEN
+ return wallet.lnworker.node_keypair.pubkey.hex() + (('@' + listen_addr) if listen_addr else '')
+
+ @command('wl')
+ async def list_channels(self, public: bool = False, private: bool = False, active: bool = False, open: bool = False, wallet: Abstract_Wallet = None):
+ """Return the list of channels in the wallet
+
+ arg:bool:public:list only public channels
+ arg:bool:private:list only private channels
+ arg:bool:open:list only open channels
+ arg:bool:active:list only active channels
+ """
+ from .lnutil import LOCAL, REMOTE, format_short_channel_id
+ if public and private:
+ raise Exception("incompatible options")
+ def _filter(chan):
+ if public and not chan.is_public():
+ return False
+ if private and chan.is_public():
+ return False
+ if active and not chan.is_redeemed():
+ return False
+ if open and not chan.is_open():
+ return False
+ return True
+
+ return [
+ {
+ 'short_channel_id': format_short_channel_id(chan.short_channel_id) if chan.short_channel_id else None,
+ 'channel_id': chan.channel_id.hex(),
+ 'channel_point': chan.funding_outpoint.to_str(),
+ 'closing_txid': chan.get_closing_height()[0] if chan.get_closing_height() else None,
+ 'state': chan.get_state().name,
+ 'peer_state': chan.peer_state.name,
+ 'remote_pubkey': chan.node_id.hex(),
+ 'local_balance': chan.balance(LOCAL)//1000,
+ 'remote_balance': chan.balance(REMOTE)//1000,
+ 'local_ctn': chan.get_latest_ctn(LOCAL),
+ 'remote_ctn': chan.get_latest_ctn(REMOTE),
+ '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 chan in wallet.lnworker.channels.values() if _filter(chan)
+ ]
+
+ @command('wl')
+ async def list_channel_backups(self, wallet: Abstract_Wallet = None):
+ """Return the list of channel backups in the wallet"""
+ # FIXME: we need to be online to display capacity of backups
+ from .lnutil import LOCAL, REMOTE, format_short_channel_id
+ return [
+ {
+ 'short_channel_id': format_short_channel_id(chan.short_channel_id) if chan.short_channel_id else None,
+ 'channel_id': chan.channel_id.hex(),
+ 'channel_point': chan.funding_outpoint.to_str(),
+ 'closing_txid': chan.get_closing_height()[0] if chan.get_closing_height() else None,
+ 'state': chan.get_state().name,
+ } for chan in wallet.lnworker.channel_backups.values()
+ ]
+
+ @command('wnl')
+ async def enable_htlc_settle(self, b: bool, wallet: Abstract_Wallet = None):
+ """
+ command used in regtests
+
+ arg:bool:b:boolean
+ """
+ wallet.lnworker.enable_htlc_settle = b
+
+ @command('n')
+ async def clear_ln_blacklist(self):
+ if self.network.path_finder:
+ self.network.path_finder.clear_blacklist()
+
+ @command('n')
+ async def reset_liquidity_hints(self):
+ if self.network.path_finder:
+ self.network.path_finder.liquidity_hints.reset_liquidity_hints()
+ self.network.path_finder.clear_blacklist()
+
+ @command('wnpl')
+ async def close_channel(self, channel_point, force=False, password=None, wallet: Abstract_Wallet = None):
+ """
+ Close a lightning channel.
+ Returns txid of closing tx.
+
+ arg:str:channel_point:channel point
+ arg:bool:force:Force closes (broadcast local commitment transaction)
+ """
+ txid, index = channel_point.split(':')
+ chan_id, _ = channel_id_from_funding_tx(txid, int(index))
+ if chan_id not in wallet.lnworker.channels:
+ raise UserFacingException(f'Unknown channel {channel_point}')
+ coro = wallet.lnworker.force_close_channel(chan_id) if force else wallet.lnworker.close_channel(chan_id)
+ return await coro
+
+ @command('wnpl')
+ async def request_force_close(self, channel_point, connection_string=None, password=None, wallet: Abstract_Wallet = None):
+ """
+ Requests the remote to force close a channel.
+ If a connection string is passed, can be used without having state or any backup for the channel.
+ Assumes that channel was originally opened with the same local peer (node_keypair).
+
+ arg:str:connection_string:Lightning network node ID or network address
+ arg:str:channel_point:channel point
+ """
+ txid, index = channel_point.split(':')
+ chan_id, _ = channel_id_from_funding_tx(txid, int(index))
+ if chan_id not in wallet.lnworker.channels and chan_id not in wallet.lnworker.channel_backups:
+ raise UserFacingException(f'Unknown channel {channel_point}')
+ await wallet.lnworker.request_force_close(chan_id, connect_str=connection_string)
+
+ @command('wpl')
+ async def export_channel_backup(self, channel_point, password=None, wallet: Abstract_Wallet = None):
+ """
+ Returns an encrypted channel backup
+
+ arg:str:channel_point:Channel outpoint
+ """
+ txid, index = channel_point.split(':')
+ chan_id, _ = channel_id_from_funding_tx(txid, int(index))
+ if chan_id not in wallet.lnworker.channels:
+ raise UserFacingException(f'Unknown channel {channel_point}')
+ return wallet.lnworker.export_channel_backup(chan_id)
+
+ @command('wl')
+ async def import_channel_backup(self, encrypted, wallet: Abstract_Wallet = None):
+ """
+ arg:str:encrypted:Encrypted channel backup
+ """
+ return wallet.lnworker.import_channel_backup(encrypted)
+
+ @command('wnpl')
+ async def get_channel_ctx(self, channel_point, password=None, iknowwhatimdoing=False, wallet: Abstract_Wallet = None):
+ """
+ return the current commitment transaction of a channel
+
+ arg:str:channel_point:Channel outpoint
+ arg:bool:iknowwhatimdoing:Acknowledge that I understand the full implications of what I am about to do
+ """
+ if not iknowwhatimdoing:
+ raise UserFacingException(
+ "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))
+ if chan_id not in wallet.lnworker.channels:
+ raise UserFacingException(f'Unknown channel {channel_point}')
+ chan = wallet.lnworker.channels[chan_id]
+ tx = chan.force_close_tx()
+ return tx.serialize()
+
+ @command('wnl')
+ async def list_channel_htlcs(self, channel_point, password=None, wallet: Abstract_Wallet = None):
+ """
+ return the settled, inflight and failed htlcs of a channel
+
+ arg:str:channel_point:Channel outpoint
+ """
+ txid, index = channel_point.split(':')
+ chan_id, _ = channel_id_from_funding_tx(txid, int(index))
+ if chan_id not in wallet.lnworker.channels:
+ raise UserFacingException(f'Unknown channel {channel_point}')
+ chan = wallet.lnworker.channels[chan_id]
+ folders = {
+ 'settled': [],
+ 'inflight': [],
+ 'failed': [],
+ }
+ for rhash, plist in chan.get_payments().items():
+ for htlc_with_status in plist:
+ if (fl := folders.get(htlc_with_status.status)) is None:
+ continue
+ fl.append({
+ 'id': htlc_with_status.htlc.htlc_id,
+ 'direction': 'OUT' if htlc_with_status.direction == SENT else 'IN',
+ 'amount': htlc_with_status.htlc.amount_msat,
+ 'timestamp': htlc_with_status.htlc.timestamp,
+ 'payment_hash': htlc_with_status.htlc.payment_hash.hex()
+ })
+ return folders
+
+ @command('wnl')
+ async def get_watchtower_ctn(self, channel_point, wallet: Abstract_Wallet = None):
+ """
+ Return the local watchtower's ctn of channel. used in regtests
+
+ arg:str:channel_point:Channel outpoint (txid:index)
+ """
+ return wallet.lnworker.get_watchtower_ctn(channel_point)
+
+ @command('wnpl')
+ async def rebalance_channels(self, from_scid, dest_scid, amount, password=None, wallet: Abstract_Wallet = None):
+ """
+ Rebalance channels.
+ If trampoline is used, channels must be with different trampolines.
+
+ arg:str:from_scid:Short channel ID
+ arg:str:dest_scid:Short channel ID
+ arg:decimal:amount:Amount (in BTC)
+
+ """
+ from .lnutil import ShortChannelID
+ from_scid = ShortChannelID.from_str(from_scid)
+ dest_scid = ShortChannelID.from_str(dest_scid)
+ from_channel = wallet.lnworker.get_channel_by_short_id(from_scid)
+ dest_channel = wallet.lnworker.get_channel_by_short_id(dest_scid)
+ amount_sat = satoshis(amount)
+ success, log = await wallet.lnworker.rebalance_channels(
+ from_channel,
+ dest_channel,
+ amount_msat=amount_sat * 1000,
+ )
+ return {
+ 'success': success,
+ 'log': [x.formatted_tuple() for x in log]
+ }
+
+ @command('wnl')
+ async def get_submarine_swap_providers(self, query_time=15, wallet: Abstract_Wallet = None):
+ """
+ Queries nostr relays for available submarine swap providers.
+
+ To configure one of the providers use:
+ setconfig swapserver_npub 'npub...'
+
+ arg:int:query_time:Optional timeout how long the relays should be queried for provider announcements. Default: 15 sec
+ """
+ sm = wallet.lnworker.swap_manager
+ async with sm.create_transport() as transport:
+ assert isinstance(transport, NostrTransport)
+ await asyncio.sleep(query_time)
+ offers = transport.get_recent_offers()
+ result = {}
+ for offer in offers:
+ result[offer.server_npub] = {
+ "percentage_fee": float(offer.pairs.percentage),
+ "max_forward_sat": offer.pairs.max_forward,
+ "max_reverse_sat": offer.pairs.max_reverse,
+ "min_amount_sat": offer.pairs.min_amount,
+ "prepayment": 2 * offer.pairs.mining_fee,
+ }
+ return result
+
+ @command('wnpl')
+ 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
+
+ arg:decimal_or_dryrun:lightning_amount:Amount to be received, in BTC. Set it to 'dryrun' to receive a value
+ arg:decimal_or_dryrun:onchain_amount:Amount to be sent, in BTC. Set it to 'dryrun' to receive a value
+ """
+ sm = wallet.lnworker.swap_manager
+ assert self.config.SWAPSERVER_NPUB or self.config.SWAPSERVER_URL, \
+ "Configure swap provider first. See 'get_submarine_swap_providers'."
+ async with sm.create_transport() as transport:
+ try:
+ await asyncio.wait_for(sm.is_initialized.wait(), timeout=15)
+ except asyncio.TimeoutError:
+ raise TimeoutError("Could not find configured swap provider. Setup another one. See 'get_submarine_swap_providers'")
+ if lightning_amount == 'dryrun':
+ 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':
+ 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(
+ transport=transport,
+ lightning_amount_sat=lightning_amount_sat,
+ expected_onchain_amount_sat=onchain_amount_sat,
+ password=password,
+ )
+
+ return {
+ 'txid': txid, # FIXME sync name with reverse_swap cmd that uses "funding_txid"
+ 'lightning_amount': format_satoshis(lightning_amount_sat),
+ 'onchain_amount': format_satoshis(onchain_amount_sat),
+ }
+
+ @command('wnpl')
+ async def reverse_swap(
+ self, lightning_amount, onchain_amount, prepayment='dryrun', password=None, wallet: Abstract_Wallet = None,
+ ):
+ """
+ Reverse submarine swap: send on Lightning, receive on-chain
+
+ arg:decimal_or_dryrun:lightning_amount:Amount to be sent, in BTC. Set it to 'dryrun' to receive a value
+ arg:decimal_or_dryrun:onchain_amount:Amount to be received, in BTC. Set it to 'dryrun' to receive a value
+ arg:decimal_or_dryrun:prepayment:Lightning payment required by the swap provider in order to cover their mining fees. This is included in lightning_amount. However, this part of the operation is not trustless; the provider is trusted to fail this payment if the swap fails.
+ """
+ sm = wallet.lnworker.swap_manager
+ assert self.config.SWAPSERVER_NPUB or self.config.SWAPSERVER_URL, \
+ "Configure swap provider first. See 'get_submarine_swap_providers'."
+ async with sm.create_transport() as transport:
+ try:
+ await asyncio.wait_for(sm.is_initialized.wait(), timeout=15)
+ except asyncio.TimeoutError:
+ raise TimeoutError("Could not find configured swap provider. Setup another one. See 'get_submarine_swap_providers'")
+ if onchain_amount == 'dryrun':
+ lightning_amount_sat = satoshis(lightning_amount)
+ onchain_amount_sat = sm.get_recv_amount(lightning_amount_sat, is_reverse=True)
+ assert prepayment == "dryrun", f"Cannot use {prepayment=} in dryrun. Set it to 'dryrun'."
+ prepayment_sat = 2 * sm.mining_fee
+ funding_txid = None
+ elif lightning_amount == 'dryrun':
+ onchain_amount_sat = satoshis(onchain_amount)
+ lightning_amount_sat = sm.get_send_amount(onchain_amount_sat, is_reverse=True)
+ assert prepayment == "dryrun", f"Cannot use {prepayment=} in dryrun. Set it to 'dryrun'."
+ prepayment_sat = 2 * sm.mining_fee
+ funding_txid = None
+ else:
+ lightning_amount_sat = satoshis(lightning_amount)
+ claim_fee = sm.get_fee_for_txbatcher()
+ onchain_amount_sat = satoshis(onchain_amount) + claim_fee
+ assert prepayment != "dryrun", "Provide the 'prepayment' obtained from the dryrun."
+ prepayment_sat = satoshis(prepayment)
+ funding_txid = await wallet.lnworker.swap_manager.reverse_swap(
+ transport=transport,
+ lightning_amount_sat=lightning_amount_sat,
+ expected_onchain_amount_sat=onchain_amount_sat,
+ prepayment_sat=prepayment_sat,
+ )
+ return {
+ 'funding_txid': funding_txid,
+ 'lightning_amount': format_satoshis(lightning_amount_sat),
+ 'onchain_amount': format_satoshis(onchain_amount_sat),
+ 'prepayment': format_satoshis(prepayment_sat)
+ }
+
+ @command('n')
+ async def convert_currency(self, from_amount=1, from_ccy='', to_ccy=''):
+ """
+ Converts the given amount of currency to another using the
+ configured exchange rate source.
+
+ arg:decimal:from_amount:Amount to convert (default=1)
+ arg:str:from_ccy:Currency to convert from
+ arg:str:to_ccy:Currency to convert to
+ """
+ if not self.daemon.fx.is_enabled():
+ raise UserFacingException("FX is disabled. To enable, run: 'electrum setconfig use_exchange_rate true'")
+ # Currency codes are uppercase
+ from_ccy = from_ccy.upper()
+ to_ccy = to_ccy.upper()
+ # Default currencies
+ if from_ccy == '':
+ from_ccy = "BTC" if to_ccy != "BTC" else self.daemon.fx.ccy
+ if to_ccy == '':
+ to_ccy = "BTC" if from_ccy != "BTC" else self.daemon.fx.ccy
+ # Get current rates
+ rate_from = self.daemon.fx.exchange.get_cached_spot_quote(from_ccy)
+ rate_to = self.daemon.fx.exchange.get_cached_spot_quote(to_ccy)
+ # Test if currencies exist
+ if rate_from.is_nan():
+ raise UserFacingException(f'Currency to convert from ({from_ccy}) is unknown or rate is unavailable')
+ if rate_to.is_nan():
+ raise UserFacingException(f'Currency to convert to ({to_ccy}) is unknown or rate is unavailable')
+ # Conversion
+ try:
+ from_amount = to_decimal(from_amount)
+ to_amount = from_amount / rate_from * rate_to
+ except InvalidOperation:
+ raise Exception("from_amount is not a number")
+ return {
+ "from_amount": self.daemon.fx.ccy_amount_str(from_amount, add_thousands_sep=False, ccy=from_ccy),
+ "to_amount": self.daemon.fx.ccy_amount_str(to_amount, add_thousands_sep=False, ccy=to_ccy),
+ "from_ccy": from_ccy,
+ "to_ccy": to_ccy,
+ "source": self.daemon.fx.exchange.name(),
+ }
+
+ @command('wnl')
+ async def send_onion_message(self, node_id_or_blinded_path_hex: str, message: str, wallet: Abstract_Wallet = None):
+ """
+ Send an onion message with onionmsg_tlv.message payload to node_id.
+
+ arg:str:node_id_or_blinded_path_hex:node id or blinded path
+ arg:str:message:Message to send
+ """
+ assert wallet
+ assert wallet.lnworker
+ assert node_id_or_blinded_path_hex
+ assert message
+
+ node_id_or_blinded_path = bfh(node_id_or_blinded_path_hex)
+ assert len(node_id_or_blinded_path) >= 33
+
+ destination_payload = {
+ 'message': {'text': message.encode('utf-8')}
+ }
+
+ try:
+ send_onion_message_to(wallet.lnworker, node_id_or_blinded_path, destination_payload)
+ return {'success': True}
+ except Exception as e:
+ msg = str(e)
+
+ return {
+ 'success': False,
+ 'msg': msg
+ }
+
+ @command('wnl')
+ async def get_blinded_path_via(self, node_id: str, dummy_hops: int = 0, wallet: Abstract_Wallet = None):
+ """
+ Create a blinded path with node_id as introduction point. Introduction point must be direct peer of me.
+
+ arg:str:node_id:Node pubkey in hex format
+ arg:int:dummy_hops:Number of dummy hops to add
+ """
+ # TODO: allow introduction_point to not be a direct peer and construct a route
+ assert wallet
+ assert node_id
+
+ pubkey = bfh(node_id)
+ assert len(pubkey) == 33, 'invalid node_id'
+
+ peer = wallet.lnworker.lnpeermgr.peers[pubkey]
+ assert peer, 'node_id not a peer'
+
+ path = [pubkey, wallet.lnworker.node_keypair.pubkey]
+ session_key = os.urandom(32)
+ blinded_path = create_blinded_path(session_key, path=path, final_recipient_data={}, dummy_hops=dummy_hops)
+
+ with io.BytesIO() as blinded_path_fd:
+ OnionWireSerializer.write_field(
+ fd=blinded_path_fd,
+ field_type='blinded_path',
+ count=1,
+ value=blinded_path)
+ encoded_blinded_path = blinded_path_fd.getvalue()
+
+ return encoded_blinded_path.hex()
+
+
+def plugin_command(s, plugin_name):
+ """Decorator to register a cli command inside a plugin. To be used within a commands.py file
+ in the plugins root."""
+ # atm all plugin commands require a daemon, cannot be run in 'offline' mode:
+ if 'n' not in s:
+ s += 'n'
+ def decorator(func):
+ assert len(plugin_name) > 0, "Plugin name must not be empty"
+ func.plugin_name = plugin_name
+ name = plugin_name + '_' + func.__name__
+ if name in known_commands or hasattr(Commands, name):
+ raise Exception(f"Command name {name} already exists. Plugin commands should not overwrite other commands.")
+ assert inspect.iscoroutinefunction(func), f"Plugin commands must be a coroutine: {name}"
+
+ @command(s)
+ @wraps(func)
+ async def func_wrapper(*args, **kwargs):
+ cmd_runner = args[0] # type: Commands
+ daemon = cmd_runner.daemon
+ assert daemon is not None
+ kwargs['plugin'] = daemon._plugins.get_plugin(plugin_name)
+ return await func(*args, **kwargs)
+
+ setattr(Commands, name, func_wrapper)
+ return func_wrapper
+ return decorator
+
+
+def eval_bool(x: str) -> bool:
+ if x == 'false':
+ return False
+ if x == 'true':
+ return True
+ # assume python, raise if malformed
+ return bool(ast.literal_eval(x))
+
+
+# don't use floats because of rounding errors
+json_loads = lambda x: json.loads(x, parse_float=lambda x: str(to_decimal(x)))
+
+
+def check_txid(txid):
+ if not is_hash256_str(txid):
+ raise UserFacingException(f"{repr(txid)} is not a txid")
+ return txid
+
+
+arg_types = {
+ 'int': int,
+ 'bool': eval_bool,
+ 'str': str,
+ 'txid': check_txid,
+ 'tx': convert_raw_tx_to_hex,
+ 'json': json_loads,
+ 'decimal': lambda x: str(to_decimal(x)),
+ 'decimal_or_dryrun': lambda x: str(to_decimal(x)) if x != 'dryrun' else x,
+ 'decimal_or_max': lambda x: str(to_decimal(x)) if not parse_max_spend(x) else x,
+}
+
+config_variables = {
+ 'addrequest': {
+ 'ssl_privkey': 'Path to your SSL private key, needed to sign the request.',
+ 'ssl_chain': 'Chain of SSL certificates, needed for signed requests. Put your certificate at the top and the root CA at the end',
+ 'url_rewrite': 'Parameters passed to str.replace(), in order to create the r= part of bitcoin: URIs. Example: \"(\'file:///var/www/\',\'https://electrum.org/\')\"',
+ },
+ 'listrequests': {
+ 'url_rewrite': 'Parameters passed to str.replace(), in order to create the r= part of bitcoin: URIs. Example: \"(\'file:///var/www/\',\'https://electrum.org/\')\"',
+ }
+}
+
+
+def set_default_subparser(self, name, args=None):
+ """see http://stackoverflow.com/questions/5176691/argparse-how-to-specify-a-default-subcommand"""
+ subparser_found = False
+ for arg in sys.argv[1:]:
+ if arg in ['-h', '--help', '--version']: # global help/version if no subparser
+ break
+ else:
+ for x in self._subparsers._actions:
+ if not isinstance(x, argparse._SubParsersAction):
+ continue
+ for sp_name in x._name_parser_map.keys():
+ if sp_name in sys.argv[1:]:
+ subparser_found = True
+ if not subparser_found:
+ # insert default in first position, this implies no
+ # global options without a sub_parsers specified
+ if args is None:
+ sys.argv.insert(1, name)
+ else:
+ args.insert(0, name)
+
+
+argparse.ArgumentParser.set_default_subparser = set_default_subparser
+
+
+# workaround https://bugs.python.org/issue23058
+# see https://github.com/nickstenning/honcho/pull/121
+
+def subparser_call(self, parser, namespace, values, option_string=None):
+ from argparse import ArgumentError, SUPPRESS, _UNRECOGNIZED_ARGS_ATTR
+ parser_name = values[0]
+ arg_strings = values[1:]
+ # set the parser name if requested
+ if self.dest is not SUPPRESS:
+ setattr(namespace, self.dest, parser_name)
+ # select the parser
+ try:
+ parser = self._name_parser_map[parser_name]
+ except KeyError:
+ tup = parser_name, ', '.join(self._name_parser_map)
+ msg = _('unknown parser {!r} (choices: {})').format(*tup)
+ raise ArgumentError(self, msg)
+ # parse all the remaining options into the namespace
+ # store any unrecognized options on the object, so that the top
+ # level parser can decide what to do with them
+ namespace, arg_strings = parser.parse_known_args(arg_strings, namespace)
+ if arg_strings:
+ vars(namespace).setdefault(_UNRECOGNIZED_ARGS_ATTR, [])
+ getattr(namespace, _UNRECOGNIZED_ARGS_ATTR).extend(arg_strings)
+
+
+argparse._SubParsersAction.__call__ = subparser_call
+
+
+def add_network_options(parser):
+ group = parser.add_argument_group('network options')
+ group.add_argument(
+ "-f", "--serverfingerprint", dest=SimpleConfig.NETWORK_SERVERFINGERPRINT.key(), 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.")
+ group.add_argument(
+ "-1", "--oneserver", action="store_true", dest=SimpleConfig.NETWORK_ONESERVER.key(), default=None,
+ help="connect to one server only")
+ group.add_argument(
+ "-s", "--server", dest=SimpleConfig.NETWORK_SERVER.key(), default=None,
+ help="set server host:port:protocol, where protocol is either t (tcp) or s (ssl)")
+ group.add_argument(
+ "-p", "--proxy", dest=SimpleConfig.NETWORK_PROXY.key(), default=None,
+ help="set proxy [type:]host:port (or 'none' to disable proxy), where type is socks4 or socks5")
+ group.add_argument(
+ "--proxyuser", dest=SimpleConfig.NETWORK_PROXY_USER.key(), default=None,
+ help="set proxy username")
+ group.add_argument(
+ "--proxypassword", dest=SimpleConfig.NETWORK_PROXY_PASSWORD.key(), default=None,
+ help="set proxy password")
+ group.add_argument(
+ "--noonion", action="store_true", dest=SimpleConfig.NETWORK_NOONION.key(), default=None,
+ help="do not try to connect to onion servers")
+ group.add_argument(
+ "--skipmerklecheck", action="store_true", dest=SimpleConfig.NETWORK_SKIPMERKLECHECK.key(), default=None,
+ help="Tolerate invalid merkle proofs from Electrum server")
+
+
+def add_global_options(parser, suppress=False):
+ group = parser.add_argument_group('global options')
+ group.add_argument(
+ "-v", dest="verbosity", default='',
+ help=argparse.SUPPRESS if suppress else "Set verbosity (log levels)")
+ group.add_argument(
+ "-D", "--dir", dest="electrum_path",
+ help=argparse.SUPPRESS if suppress else "electrum directory")
+ group.add_argument(
+ "-w", "--wallet", dest="wallet_path",
+ help=argparse.SUPPRESS if suppress else "wallet path")
+ group.add_argument(
+ "-P", "--portable", action="store_true", dest="portable", default=False,
+ help=argparse.SUPPRESS if suppress else "Use local 'electrum_data' directory")
+ for chain in constants.NETS_LIST:
+ group.add_argument(
+ f"--{chain.cli_flag()}", action="store_true", dest=chain.config_key(), default=False,
+ help=argparse.SUPPRESS if suppress else f"Use {chain.NET_NAME} chain")
+ group.add_argument(
+ "-o", "--offline", action="store_true", dest=SimpleConfig.NETWORK_OFFLINE.key(), default=None,
+ help=argparse.SUPPRESS if suppress else "Run offline")
+ group.add_argument(
+ "--rpcuser", dest=SimpleConfig.RPC_USERNAME.key(), default=argparse.SUPPRESS,
+ help=argparse.SUPPRESS if suppress else "RPC user")
+ group.add_argument(
+ "--rpcpassword", dest=SimpleConfig.RPC_PASSWORD.key(), default=argparse.SUPPRESS,
+ help=argparse.SUPPRESS if suppress else "RPC password")
+ group.add_argument(
+ "--forgetconfig", action="store_true", dest=SimpleConfig.CONFIG_FORGET_CHANGES.key(), default=None,
+ help=argparse.SUPPRESS if suppress else "Forget config on exit")
+ group.add_argument(
+ # Note: default value is False and not None, so that behaviour cannot be modified by editing the config file
+ "--nohardening", action="store_true", dest=SimpleConfig.DISABLE_MEMORY_HARDENING_LINUX.key(), default=False,
+ help=argparse.SUPPRESS if suppress else "Disable memory hardening (linux)")
+
+
+def get_simple_parser():
+ """ simple parser that figures out the path of the config file and ignore unknown args """
+ from optparse import OptionParser, BadOptionError, AmbiguousOptionError
+
+ class PassThroughOptionParser(OptionParser):
+ # see https://stackoverflow.com/questions/1885161/how-can-i-get-optparses-optionparser-to-ignore-invalid-options
+ def _process_args(self, largs, rargs, values):
+ while rargs:
+ try:
+ OptionParser._process_args(self, largs, rargs, values)
+ except (BadOptionError, AmbiguousOptionError) as e:
+ largs.append(e.opt_str)
+
+ parser = PassThroughOptionParser()
+ parser.add_option("-D", "--dir", dest="electrum_path", help="electrum directory")
+ parser.add_option("-P", "--portable", action="store_true", dest="portable", default=False, help="Use local 'electrum_data' directory")
+ for chain in constants.NETS_LIST:
+ parser.add_option(f"--{chain.cli_flag()}", action="store_true", dest=chain.config_key(), default=False, help=f"Use {chain.NET_NAME} chain")
+ return parser
+
+
+def get_parser():
+ # create main parser
+ parser = argparse.ArgumentParser(
+ epilog="Run 'electrum help ' to see the help for a command")
+ parser.add_argument("--version", dest="cmd", action='store_const', const='version', help="Return the version of Electrum.")
+ add_global_options(parser)
+ subparsers = parser.add_subparsers(dest='cmd', metavar='')
+ # gui
+ parser_gui = subparsers.add_parser('gui', description="Run Electrum's Graphical User Interface.", help="Run GUI (default)")
+ parser_gui.add_argument("url", nargs='?', default=None, help="bitcoin URI")
+ parser_gui.add_argument("-g", "--gui", dest=SimpleConfig.GUI_NAME.key(), help="select graphical user interface", choices=['qt', 'text', 'stdio', 'qml'])
+ parser_gui.add_argument("-m", action="store_true", dest=SimpleConfig.GUI_QT_HIDE_ON_STARTUP.key(), default=False, help="hide GUI on startup")
+ parser_gui.add_argument("-L", "--lang", dest=SimpleConfig.LOCALIZATION_LANGUAGE.key(), default=None, help="default language used in GUI")
+ parser_gui.add_argument("--daemon", action="store_true", dest="daemon", default=False, help="keep daemon running after GUI is closed")
+ parser_gui.add_argument("--nosegwit", action="store_true", dest=SimpleConfig.WIZARD_DONT_CREATE_SEGWIT.key(), default=False, help="Do not create segwit wallets")
+ add_network_options(parser_gui)
+ add_global_options(parser_gui)
+ # daemon
+ parser_daemon = subparsers.add_parser('daemon', help="Run Daemon")
+ parser_daemon.add_argument("-d", "--detached", action="store_true", dest="detach", default=False, help="run daemon in detached mode")
+ # FIXME: all these options are rpc-server-side. The CLI client-side cannot use e.g. --rpcport,
+ # instead it reads it from the daemon lockfile.
+ parser_daemon.add_argument("--rpchost", dest=SimpleConfig.RPC_HOST.key(), default=argparse.SUPPRESS, help="RPC host")
+ parser_daemon.add_argument("--rpcport", dest=SimpleConfig.RPC_PORT.key(), type=int, default=argparse.SUPPRESS, help="RPC port")
+ parser_daemon.add_argument("--rpcsock", dest=SimpleConfig.RPC_SOCKET_TYPE.key(), default=None, help="what socket type to which to bind RPC daemon", choices=['unix', 'tcp', 'auto'])
+ parser_daemon.add_argument("--rpcsockpath", dest=SimpleConfig.RPC_SOCKET_FILEPATH.key(), help="where to place RPC file socket")
+ add_network_options(parser_daemon)
+ add_global_options(parser_daemon)
+ # commands
+ for cmdname in sorted(known_commands.keys()):
+ cmd = known_commands[cmdname]
+ p = subparsers.add_parser(
+ cmdname,
+ description=cmd.description,
+ help=cmd.short_description,
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="Run 'electrum -h' to see the list of global options",
+ )
+ for optname, default in zip(cmd.options, cmd.defaults):
+ if optname in ['wallet_path', 'wallet', 'plugin']:
+ continue
+ if optname == 'password':
+ p.add_argument("--password", dest='password', help="Wallet password. Use '--password :' if you want a prompt.")
+ continue
+ help = cmd.arg_descriptions.get(optname)
+ if not help:
+ print(f'undocumented argument {cmdname}::{optname}', file=sys.stderr)
+ action = "store_true" if default is False else 'store'
+ if action == 'store':
+ type_descriptor = cmd.arg_types.get(optname)
+ _type = arg_types.get(type_descriptor, str)
+ p.add_argument('--' + optname, dest=optname, action=action, default=default, help=help, type=_type)
+ else:
+ p.add_argument('--' + optname, dest=optname, action=action, default=default, help=help)
+ add_global_options(p, suppress=True)
+
+ for param in cmd.params:
+ if param in ['wallet_path', 'wallet']:
+ continue
+ help = cmd.arg_descriptions.get(param)
+ if not help:
+ print(f'undocumented argument {cmdname}::{param}', file=sys.stderr)
+ type_descriptor = cmd.arg_types.get(param)
+ _type = arg_types.get(type_descriptor)
+ if help is not None and _type is None:
+ print(f'unknown type \'{_type}\' for {cmdname}::{param}', file=sys.stderr)
+ p.add_argument(param, help=help, type=_type)
+
+ cvh = config_variables.get(cmdname)
+ if cvh:
+ group = p.add_argument_group('configuration variables', '(set with setconfig/getconfig)')
+ for k, v in cvh.items():
+ group.add_argument(k, nargs='?', help=v)
+
+ # 'gui' is the default command
+ # note: set_default_subparser modifies sys.argv
+ parser.set_default_subparser('gui')
+ return parser
diff --git a/electrum/constants.py b/electrum/constants.py
new file mode 100644
index 000000000000..6026b9c4ac05
--- /dev/null
+++ b/electrum/constants.py
@@ -0,0 +1,273 @@
+# -*- coding: utf-8 -*-
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2018 The Electrum developers
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import copy
+import os
+import json
+from typing import Sequence, Tuple, Mapping, Type, List, Optional
+
+from .lntransport import LNPeerAddr
+from .util import inv_dict, all_subclasses, classproperty
+from . import bitcoin
+
+
+def read_json(filename, default=None):
+ path = os.path.join(os.path.dirname(__file__), filename)
+ try:
+ with open(path, 'r') as f:
+ r = json.loads(f.read())
+ except Exception:
+ if default is None:
+ # Sometimes it's better to hard-fail: the file might be missing
+ # due to a packaging issue, which might otherwise go unnoticed.
+ raise
+ r = default
+ return r
+
+
+def create_fallback_node_list(fallback_nodes_dict: dict[str, dict]) -> List[LNPeerAddr]:
+ """Take a json dict of fallback nodes like: k:node_id, v:{k:'host', k:'port'} and return LNPeerAddr list"""
+ fallback_nodes = []
+ for node_id, address in fallback_nodes_dict.items():
+ fallback_nodes.append(
+ LNPeerAddr(host=address['host'], port=int(address['port']), pubkey=bytes.fromhex(node_id)))
+ return fallback_nodes
+
+
+GIT_REPO_URL = "https://github.com/spesmilo/electrum"
+GIT_REPO_ISSUES_URL = "https://github.com/spesmilo/electrum/issues"
+RELEASE_NOTES_URL = "https://raw.githubusercontent.com/spesmilo/electrum/refs/heads/master/RELEASE-NOTES"
+BIP39_WALLET_FORMATS = read_json('bip39_wallet_formats.json')
+
+
+class AbstractNet:
+
+ NET_NAME: str
+ TESTNET: bool
+ WIF_PREFIX: int
+ ADDRTYPE_P2PKH: int
+ ADDRTYPE_P2SH: int
+ SEGWIT_HRP: str
+ BOLT11_HRP: str
+ GENESIS: str
+ BLOCK_HEIGHT_FIRST_LIGHTNING_CHANNELS: int = 0
+ BIP44_COIN_TYPE: int
+ LN_REALM_BYTE: int
+ DEFAULT_PORTS: Mapping[str, str]
+ LN_DNS_SEEDS: Sequence[str]
+ XPRV_HEADERS: Mapping[str, int]
+ XPRV_HEADERS_INV: Mapping[int, str]
+ XPUB_HEADERS: Mapping[str, int]
+ XPUB_HEADERS_INV: Mapping[int, str]
+
+ @classmethod
+ def max_checkpoint(cls) -> int:
+ return max(0, len(cls.CHECKPOINTS) * 2016 - 1)
+
+ @classmethod
+ def rev_genesis_bytes(cls) -> bytes:
+ return bytes.fromhex(cls.GENESIS)[::-1]
+
+ @classmethod
+ def set_as_network(cls) -> None:
+ global net
+ net = cls
+
+ _cached_default_servers = None
+ @classproperty
+ def DEFAULT_SERVERS(cls) -> Mapping[str, Mapping[str, str]]:
+ if cls._cached_default_servers is None:
+ default_file = {} if cls.TESTNET else None # for mainnet we hard-fail if the file is missing.
+ cls._cached_default_servers = read_json(os.path.join('chains', cls.NET_NAME, 'servers.json'), default_file)
+ d = cls._cached_default_servers
+ return copy.deepcopy(d)
+
+ _cached_fallback_lnnodes = None
+ @classproperty
+ def FALLBACK_LN_NODES(cls) -> Sequence[LNPeerAddr]:
+ if cls._cached_fallback_lnnodes is None:
+ default_file = {} if cls.TESTNET else None # for mainnet we hard-fail if the file is missing.
+ d = read_json(os.path.join('chains', cls.NET_NAME, 'fallback_lnnodes.json'), default_file)
+ cls._cached_fallback_lnnodes = create_fallback_node_list(d)
+ return cls._cached_fallback_lnnodes
+
+ _cached_checkpoints = None
+ @classproperty
+ def CHECKPOINTS(cls) -> Sequence[Tuple[str, int]]:
+ if cls._cached_checkpoints is None:
+ default_file = [] if cls.TESTNET else None # for mainnet we hard-fail if the file is missing.
+ cls._cached_checkpoints = read_json(os.path.join('chains', cls.NET_NAME, 'checkpoints.json'), default_file)
+ return cls._cached_checkpoints
+
+ @classmethod
+ def datadir_subdir(cls) -> Optional[str]:
+ """The name of the folder in the filesystem.
+ None means top-level, used by mainnet.
+ """
+ return cls.NET_NAME
+
+ @classmethod
+ def cli_flag(cls) -> str:
+ """as used in e.g. `$ run_electrum --testnet4`"""
+ return cls.NET_NAME
+
+ @classmethod
+ def config_key(cls) -> str:
+ """as used for SimpleConfig.get()"""
+ return cls.NET_NAME
+
+
+class BitcoinMainnet(AbstractNet):
+
+ NET_NAME = "mainnet"
+ TESTNET = False
+ WIF_PREFIX = 0x80
+ ADDRTYPE_P2PKH = 0
+ ADDRTYPE_P2SH = 5
+ SEGWIT_HRP = "bc"
+ BOLT11_HRP = SEGWIT_HRP
+ GENESIS = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"
+ DEFAULT_PORTS = {'t': '50001', 's': '50002'}
+ BLOCK_HEIGHT_FIRST_LIGHTNING_CHANNELS = 497000
+
+ XPRV_HEADERS = {
+ 'standard': 0x0488ade4, # xprv
+ 'p2wpkh-p2sh': 0x049d7878, # yprv
+ 'p2wsh-p2sh': 0x0295b005, # Yprv
+ 'p2wpkh': 0x04b2430c, # zprv
+ 'p2wsh': 0x02aa7a99, # Zprv
+ }
+ XPRV_HEADERS_INV = inv_dict(XPRV_HEADERS)
+ XPUB_HEADERS = {
+ 'standard': 0x0488b21e, # xpub
+ 'p2wpkh-p2sh': 0x049d7cb2, # ypub
+ 'p2wsh-p2sh': 0x0295b43f, # Ypub
+ 'p2wpkh': 0x04b24746, # zpub
+ 'p2wsh': 0x02aa7ed3, # Zpub
+ }
+ XPUB_HEADERS_INV = inv_dict(XPUB_HEADERS)
+ BIP44_COIN_TYPE = 0
+ LN_REALM_BYTE = 0
+ LN_DNS_SEEDS = [
+ 'nodes.lightning.directory.',
+ 'lseed.bitcoinstats.com.',
+ 'lseed.darosior.ninja',
+ ]
+
+ @classmethod
+ def datadir_subdir(cls):
+ return None
+
+
+class BitcoinTestnet(AbstractNet):
+
+ NET_NAME = "testnet"
+ TESTNET = True
+ WIF_PREFIX = 0xef
+ ADDRTYPE_P2PKH = 111
+ ADDRTYPE_P2SH = 196
+ SEGWIT_HRP = "tb"
+ BOLT11_HRP = SEGWIT_HRP
+ GENESIS = "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943"
+ DEFAULT_PORTS = {'t': '51001', 's': '51002'}
+
+ XPRV_HEADERS = {
+ 'standard': 0x04358394, # tprv
+ 'p2wpkh-p2sh': 0x044a4e28, # uprv
+ 'p2wsh-p2sh': 0x024285b5, # Uprv
+ 'p2wpkh': 0x045f18bc, # vprv
+ 'p2wsh': 0x02575048, # Vprv
+ }
+ XPRV_HEADERS_INV = inv_dict(XPRV_HEADERS)
+ XPUB_HEADERS = {
+ 'standard': 0x043587cf, # tpub
+ 'p2wpkh-p2sh': 0x044a5262, # upub
+ 'p2wsh-p2sh': 0x024289ef, # Upub
+ 'p2wpkh': 0x045f1cf6, # vpub
+ 'p2wsh': 0x02575483, # Vpub
+ }
+ XPUB_HEADERS_INV = inv_dict(XPUB_HEADERS)
+ BIP44_COIN_TYPE = 1
+ LN_REALM_BYTE = 1
+ LN_DNS_SEEDS = [ # TODO investigate this again
+ #'test.nodes.lightning.directory.', # times out.
+ #'lseed.bitcoinstats.com.', # ignores REALM byte and returns mainnet peers...
+ ]
+
+
+class BitcoinTestnet4(BitcoinTestnet):
+
+ NET_NAME = "testnet4"
+ GENESIS = "00000000da84f2bafbbc53dee25a72ae507ff4914b867c565be350b0da8bf043"
+ LN_DNS_SEEDS = []
+
+
+class BitcoinRegtest(BitcoinTestnet):
+
+ NET_NAME = "regtest"
+ SEGWIT_HRP = "bcrt"
+ BOLT11_HRP = SEGWIT_HRP
+ GENESIS = "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206"
+ LN_DNS_SEEDS = []
+
+
+class BitcoinSimnet(BitcoinTestnet):
+
+ NET_NAME = "simnet"
+ WIF_PREFIX = 0x64
+ ADDRTYPE_P2PKH = 0x3f
+ ADDRTYPE_P2SH = 0x7b
+ SEGWIT_HRP = "sb"
+ BOLT11_HRP = SEGWIT_HRP
+ GENESIS = "683e86bd5c6d110d91b94b97137ba6bfe02dbbdb8e3dff722a669b5d69d77af6"
+ LN_DNS_SEEDS = []
+
+
+class BitcoinSignet(BitcoinTestnet):
+
+ NET_NAME = "signet"
+ BOLT11_HRP = "tbs"
+ GENESIS = "00000008819873e925422c1ff0f99f7cc9bbb232af63a077a480a3633bee1ef6"
+ LN_DNS_SEEDS = []
+
+
+class BitcoinMutinynet(BitcoinTestnet):
+
+ NET_NAME = "mutinynet"
+ BOLT11_HRP = "tbs"
+ GENESIS = "00000008819873e925422c1ff0f99f7cc9bbb232af63a077a480a3633bee1ef6"
+ LN_DNS_SEEDS = []
+
+
+NETS_LIST = tuple(all_subclasses(AbstractNet)) # type: Sequence[Type[AbstractNet]]
+NETS_LIST = tuple(sorted(NETS_LIST, key=lambda x: x.NET_NAME))
+
+assert len(NETS_LIST) == len(set([chain.NET_NAME for chain in NETS_LIST])), "NET_NAME must be unique for each concrete AbstractNet"
+assert len(NETS_LIST) == len(set([chain.datadir_subdir() for chain in NETS_LIST])), "datadir must be unique for each concrete AbstractNet"
+assert len(NETS_LIST) == len(set([chain.cli_flag() for chain in NETS_LIST])), "cli_flag must be unique for each concrete AbstractNet"
+assert len(NETS_LIST) == len(set([chain.config_key() for chain in NETS_LIST])), "config_key must be unique for each concrete AbstractNet"
+
+# don't import net directly, import the module instead (so that net is singleton)
+net = BitcoinMainnet # type: Type[AbstractNet]
diff --git a/electrum/contacts.py b/electrum/contacts.py
new file mode 100644
index 000000000000..d3aa4da1f233
--- /dev/null
+++ b/electrum/contacts.py
@@ -0,0 +1,185 @@
+# Electrum - Lightweight Bitcoin Client
+# Copyright (c) 2015 Thomas Voegtlin
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+import re
+from typing import Optional, Tuple, Dict, Any, TYPE_CHECKING
+import asyncio
+import dns
+from dns.exception import DNSException
+
+from . import bitcoin
+from . import dnssec
+from .util import read_json_file, write_json_file, to_string, is_valid_email
+from .logging import Logger, get_logger
+from .util import trigger_callback, get_asyncio_loop
+
+if TYPE_CHECKING:
+ from .wallet_db import WalletDB
+ from .simple_config import SimpleConfig
+
+
+_logger = get_logger(__name__)
+
+
+class AliasNotFoundException(Exception):
+ pass
+
+
+class Contacts(dict, Logger):
+
+ def __init__(self, db: 'WalletDB'):
+ Logger.__init__(self)
+ self.db = db
+ d = self.db.get('contacts', {})
+ try:
+ self.update(d)
+ except Exception:
+ return
+ # backward compatibility
+ for k, v in self.items():
+ _type, n = v
+ if _type == 'address' and bitcoin.is_address(n):
+ self.pop(k)
+ self[n] = ('address', k)
+
+ def save(self):
+ self.db.put('contacts', dict(self))
+ trigger_callback('contacts_updated')
+
+ def import_file(self, path):
+ data = read_json_file(path)
+ data = self._validate(data)
+ self.update(data)
+ self.save()
+
+ def export_file(self, path):
+ write_json_file(path, self)
+
+ def __setitem__(self, key, value):
+ dict.__setitem__(self, key, value)
+ self.save()
+
+ def pop(self, key):
+ if key in self.keys():
+ res = dict.pop(self, key)
+ self.save()
+ return res
+ return None
+
+ async def resolve(self, k) -> dict:
+ if bitcoin.is_address(k):
+ return {
+ 'address': k,
+ 'type': 'address'
+ }
+ for address, (_type, label) in self.items():
+ if k.casefold() != label.casefold():
+ continue
+ if _type in ('address', 'lnaddress'):
+ return {
+ 'address': address,
+ 'type': 'contact'
+ }
+ if openalias := await self.resolve_openalias(k):
+ return openalias
+ raise AliasNotFoundException("Invalid Bitcoin address or alias", k)
+
+ @classmethod
+ async def resolve_openalias(cls, url: str) -> Dict[str, Any]:
+ out = await cls._resolve_openalias(url)
+ if out:
+ address, name = out
+ return {
+ 'address': address,
+ 'name': name,
+ 'type': 'openalias',
+ }
+ return {}
+
+ def by_name(self, name):
+ for k in self.keys():
+ _type, addr = self[k]
+ if addr.casefold() == name.casefold():
+ return {
+ 'name': addr,
+ 'type': _type,
+ 'address': k
+ }
+ return None
+
+ def fetch_openalias(self, config: 'SimpleConfig'):
+ self.alias_info = None
+ alias = config.OPENALIAS_ID
+ if alias:
+ alias = str(alias)
+ async def f():
+ self.alias_info = await self._resolve_openalias(alias)
+ trigger_callback('alias_received')
+ asyncio.run_coroutine_threadsafe(f(), get_asyncio_loop())
+
+ @classmethod
+ async def _resolve_openalias(cls, url: str) -> Optional[Tuple[str, str]]:
+ # support email-style addresses, per the OA standard
+ url = url.replace('@', '.')
+ try:
+ records, validated = await dnssec.query(url, dns.rdatatype.TXT)
+ except DNSException as e:
+ _logger.info(f'Error resolving openalias: {repr(e)}')
+ return None
+ if not validated: # enforce DNSSEC validation. without it, DNS is completely insecure
+ _logger.info(f"DNSSEC validation failed for {url=!r}, or maybe dependencies are missing and could not even try.")
+ return None
+ prefix = 'btc'
+ for record in records:
+ if record.rdtype != dns.rdatatype.TXT:
+ continue
+ string = to_string(record.strings[0], 'utf8')
+ if string.startswith('oa1:' + prefix):
+ address = cls.find_regex(string, r'recipient_address=([A-Za-z0-9]+)')
+ name = cls.find_regex(string, r'recipient_name=([^;]+)')
+ if not name:
+ name = address
+ if not address:
+ continue
+ return address, name
+ return None
+
+ @staticmethod
+ def find_regex(haystack, needle):
+ regex = re.compile(needle)
+ try:
+ return regex.search(haystack).groups()[0]
+ except AttributeError:
+ return None
+
+ def _validate(self, data):
+ for k, v in list(data.items()):
+ if k == 'contacts':
+ return self._validate(v)
+ if not (bitcoin.is_address(k) or is_valid_email(k)):
+ data.pop(k)
+ else:
+ _type, _ = v
+ if _type not in ('address', 'lnaddress'):
+ data.pop(k)
+ return data
+
diff --git a/electrum/crypto.py b/electrum/crypto.py
new file mode 100644
index 000000000000..ad46e38b3783
--- /dev/null
+++ b/electrum/crypto.py
@@ -0,0 +1,502 @@
+# -*- coding: utf-8 -*-
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2018 The Electrum developers
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import base64
+import binascii
+import os
+import sys
+import hashlib
+import hmac
+from typing import Union, Mapping, Optional
+
+import electrum_ecc as ecc
+
+from .util import assert_bytes, InvalidPassword, to_bytes, to_string, WalletFileException, versiontuple
+from .i18n import _
+from .logging import get_logger
+
+_logger = get_logger(__name__)
+
+
+HAS_PYAES = False
+try:
+ import pyaes
+except Exception:
+ pass
+else:
+ HAS_PYAES = True
+
+HAS_CRYPTODOME = False
+MIN_CRYPTODOME_VERSION = "3.7"
+try:
+ import Cryptodome
+ if versiontuple(Cryptodome.__version__) < versiontuple(MIN_CRYPTODOME_VERSION):
+ _logger.warning(f"found module 'Cryptodome' but it is too old: {Cryptodome.__version__}<{MIN_CRYPTODOME_VERSION}")
+ raise Exception()
+ 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 Exception:
+ pass
+else:
+ HAS_CRYPTODOME = True
+
+HAS_CRYPTOGRAPHY = False
+MIN_CRYPTOGRAPHY_VERSION = "2.1"
+try:
+ import cryptography
+ if versiontuple(cryptography.__version__) < versiontuple(MIN_CRYPTOGRAPHY_VERSION):
+ _logger.warning(f"found module 'cryptography' but it is too old: {cryptography.__version__}<{MIN_CRYPTOGRAPHY_VERSION}")
+ raise Exception()
+ 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 Exception:
+ pass
+else:
+ HAS_CRYPTOGRAPHY = True
+
+
+if not (HAS_CRYPTODOME or HAS_CRYPTOGRAPHY):
+ raise ImportError(f"Error: at least one of ('pycryptodomex', 'cryptography') needs to be installed.")
+
+
+def version_info() -> Mapping[str, Optional[str]]:
+ ret = {}
+ if HAS_PYAES:
+ ret["pyaes.version"] = ".".join(map(str, pyaes.VERSION[:3]))
+ else:
+ ret["pyaes.version"] = None
+ if HAS_CRYPTODOME:
+ ret["cryptodome.version"] = Cryptodome.__version__
+ if hasattr(Cryptodome, "__path__"):
+ ret["cryptodome.path"] = ", ".join(Cryptodome.__path__ or [])
+ else:
+ ret["cryptodome.version"] = None
+ if HAS_CRYPTOGRAPHY:
+ ret["cryptography.version"] = cryptography.__version__
+ if hasattr(cryptography, "__path__"):
+ ret["cryptography.path"] = ", ".join(cryptography.__path__ or [])
+ else:
+ ret["cryptography.version"] = None
+ return ret
+
+
+class InvalidPadding(Exception):
+ pass
+
+
+class CiphertextFormatError(Exception):
+ pass
+
+
+def append_PKCS7_padding(data: bytes) -> bytes:
+ assert_bytes(data)
+ padlen = 16 - (len(data) % 16)
+ return data + bytes([padlen]) * padlen
+
+
+def strip_PKCS7_padding(data: bytes) -> bytes:
+ assert_bytes(data)
+ if len(data) % 16 != 0 or len(data) == 0:
+ raise InvalidPadding("invalid length")
+ padlen = data[-1]
+ if not (0 < padlen <= 16):
+ raise InvalidPadding("invalid padding byte (out of range)")
+ for i in data[-padlen:]:
+ if i != padlen:
+ raise InvalidPadding("invalid padding byte (inconsistent)")
+ return data[0:-padlen]
+
+
+def aes_encrypt_with_iv(key: bytes, iv: bytes, data: bytes) -> bytes:
+ assert_bytes(key, iv, data)
+ data = append_PKCS7_padding(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()
+ elif HAS_PYAES:
+ aes_cbc = pyaes.AESModeOfOperationCBC(key, iv=iv)
+ aes = pyaes.Encrypter(aes_cbc, padding=pyaes.PADDING_NONE)
+ e = aes.feed(data) + aes.feed() # empty aes.feed() flushes buffer
+ else:
+ raise Exception("no AES backend found")
+ return e
+
+
+def aes_decrypt_with_iv(key: bytes, iv: bytes, data: bytes) -> bytes:
+ assert_bytes(key, iv, data)
+ 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()
+ elif HAS_PYAES:
+ aes_cbc = pyaes.AESModeOfOperationCBC(key, iv=iv)
+ aes = pyaes.Decrypter(aes_cbc, padding=pyaes.PADDING_NONE)
+ data = aes.feed(data) + aes.feed() # empty aes.feed() flushes buffer
+ else:
+ raise Exception("no AES backend found")
+ try:
+ return strip_PKCS7_padding(data)
+ except InvalidPadding:
+ raise InvalidPassword()
+
+
+def EncodeAES_bytes(secret: bytes, msg: bytes) -> bytes:
+ assert_bytes(msg)
+ iv = bytes(os.urandom(16))
+ ct = aes_encrypt_with_iv(secret, iv, msg)
+ return iv + ct
+
+
+def DecodeAES_bytes(secret: bytes, ciphertext: bytes) -> bytes:
+ assert_bytes(ciphertext)
+ iv, e = ciphertext[:16], ciphertext[16:]
+ s = aes_decrypt_with_iv(secret, iv, e)
+ return s
+
+
+PW_HASH_VERSION_LATEST = 1
+KNOWN_PW_HASH_VERSIONS = (1, 2,)
+SUPPORTED_PW_HASH_VERSIONS = (1,)
+assert PW_HASH_VERSION_LATEST in KNOWN_PW_HASH_VERSIONS
+assert PW_HASH_VERSION_LATEST in SUPPORTED_PW_HASH_VERSIONS
+
+
+class UnexpectedPasswordHashVersion(InvalidPassword, WalletFileException):
+ def __init__(self, version):
+ InvalidPassword.__init__(self)
+ WalletFileException.__init__(self)
+ self.version = version
+
+ def __str__(self):
+ return "{unexpected}: {version}\n{instruction}".format(
+ unexpected=_("Unexpected password hash version"),
+ version=self.version,
+ instruction=_('You are most likely using an outdated version of Electrum. Please update.'))
+
+
+class UnsupportedPasswordHashVersion(InvalidPassword, WalletFileException):
+ def __init__(self, version):
+ InvalidPassword.__init__(self)
+ WalletFileException.__init__(self)
+ self.version = version
+
+ def __str__(self):
+ return "{unsupported}: {version}\n{instruction}".format(
+ unsupported=_("Unsupported password hash version"),
+ version=self.version,
+ instruction=f"To open this wallet, try 'git checkout password_v{self.version}'.\n"
+ "Alternatively, restore from seed.")
+
+
+def _hash_password(password: Union[bytes, str], *, version: int) -> bytes:
+ pw = to_bytes(password, 'utf8')
+ if version not in SUPPORTED_PW_HASH_VERSIONS:
+ raise UnsupportedPasswordHashVersion(version)
+ if version == 1:
+ return sha256d(pw)
+ else:
+ assert version not in KNOWN_PW_HASH_VERSIONS
+ raise UnexpectedPasswordHashVersion(version)
+
+
+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, data)
+ return ciphertext
+
+
+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)
+ # derive key from password
+ secret = _hash_password(password, version=version)
+ # decrypt given data
+ try:
+ 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)
+ try:
+ data_bytes = bytes(base64.b64decode(data, validate=True))
+ except binascii.Error as e:
+ raise CiphertextFormatError("ciphertext not valid base64") from e
+ 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"""
+ try:
+ data_bytes = bytes(base64.b64decode(data, validate=True))
+ except binascii.Error as e:
+ raise CiphertextFormatError("ciphertext not valid base64") from e
+ 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')
+ return bytes(hashlib.sha256(x).digest())
+
+
+def sha256d(x: Union[bytes, str]) -> bytes:
+ x = to_bytes(x, 'utf8')
+ out = bytes(sha256(sha256(x)))
+ return out
+
+
+def hash_160(x: bytes) -> bytes:
+ return ripemd(sha256(x))
+
+def ripemd(x: bytes) -> bytes:
+ try:
+ md = hashlib.new('ripemd160')
+ md.update(x)
+ return md.digest()
+ except BaseException:
+ # ripemd160 is not guaranteed to be available in hashlib on all platforms.
+ # Historically, our Android builds had hashlib/openssl which did not have it.
+ # see https://github.com/spesmilo/electrum/issues/7093
+ # We bundle a pure python implementation as fallback that gets used now:
+ from . import ripemd
+ md = ripemd.new(x)
+ return md.digest()
+
+
+def hmac_oneshot(key: bytes, msg: bytes, digest) -> bytes:
+ return hmac.digest(key, msg, 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))
+ assert len(key) == 32, f"unexpected key size: {len(key)} (expected: 32)"
+ assert len(nonce) == 12, f"unexpected nonce size: {len(nonce)} (expected: 12)"
+ 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))
+ assert len(key) == 32, f"unexpected key size: {len(key)} (expected: 32)"
+ assert len(nonce) == 12, f"unexpected nonce size: {len(nonce)} (expected: 12)"
+ 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:
+ """note: for any new protocol you design, please consider using chacha20_poly1305_encrypt instead
+ (for its Authenticated Encryption property).
+ """
+ assert isinstance(key, (bytes, bytearray))
+ assert isinstance(nonce, (bytes, bytearray))
+ assert isinstance(data, (bytes, bytearray))
+ assert len(key) == 32, f"unexpected key size: {len(key)} (expected: 32)"
+ assert len(nonce) in (8, 12), f"unexpected nonce size: {len(nonce)} (expected: 8 or 12)"
+ if HAS_CRYPTODOME:
+ cipher = CD_ChaCha20.new(key=key, nonce=nonce)
+ return cipher.encrypt(data)
+ if HAS_CRYPTOGRAPHY:
+ nonce = bytes(16 - len(nonce)) + 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")
+
+
+def chacha20_decrypt(*, key: bytes, nonce: bytes, data: bytes) -> bytes:
+ assert isinstance(key, (bytes, bytearray))
+ assert isinstance(nonce, (bytes, bytearray))
+ assert isinstance(data, (bytes, bytearray))
+ assert len(key) == 32, f"unexpected key size: {len(key)} (expected: 32)"
+ assert len(nonce) in (8, 12), f"unexpected nonce size: {len(nonce)} (expected: 8 or 12)"
+ if HAS_CRYPTODOME:
+ cipher = CD_ChaCha20.new(key=key, nonce=nonce)
+ return cipher.decrypt(data)
+ if HAS_CRYPTOGRAPHY:
+ nonce = bytes(16 - len(nonce)) + nonce # cryptography wants 16 byte nonces
+ algo = CG_algorithms.ChaCha20(key=key, nonce=nonce)
+ cipher = CG_Cipher(algo, mode=None, backend=CG_default_backend())
+ decryptor = cipher.decryptor()
+ return decryptor.update(data)
+ raise Exception("no chacha20 backend found")
+
+
+def ecies_encrypt_message(
+ ec_pubkey: 'ecc.ECPubkey',
+ message: bytes,
+ *,
+ magic: bytes = b'BIE1',
+) -> bytes:
+ """
+ ECIES encryption/decryption methods; AES-128-CBC with PKCS7 is used as the cipher; hmac-sha256 is used as the mac
+ """
+ assert_bytes(message)
+ ephemeral = ecc.ECPrivkey.generate_random_key()
+ ecdh_key = (ec_pubkey * ephemeral.secret_scalar).get_public_key_bytes(compressed=True)
+ key = hashlib.sha512(ecdh_key).digest()
+ iv, key_e, key_m = key[0:16], key[16:32], key[32:]
+ ciphertext = aes_encrypt_with_iv(key_e, iv, message)
+ ephemeral_pubkey = ephemeral.get_public_key_bytes(compressed=True)
+ encrypted = magic + ephemeral_pubkey + ciphertext
+ mac = hmac_oneshot(key_m, encrypted, hashlib.sha256)
+ return base64.b64encode(encrypted + mac)
+
+
+def ecies_decrypt_message(
+ ec_privkey: 'ecc.ECPrivkey',
+ encrypted: Union[str, bytes],
+ *,
+ magic: bytes = b'BIE1',
+) -> bytes:
+ encrypted = base64.b64decode(encrypted, validate=True) # type: bytes
+ if len(encrypted) < 85:
+ raise Exception('invalid ciphertext: length')
+ magic_found = encrypted[:4]
+ ephemeral_pubkey_bytes = encrypted[4:37]
+ ciphertext = encrypted[37:-32]
+ mac = encrypted[-32:]
+ if magic_found != magic:
+ raise Exception('invalid ciphertext: invalid magic bytes')
+ try:
+ ephemeral_pubkey = ecc.ECPubkey(ephemeral_pubkey_bytes)
+ except ecc.InvalidECPointException as e:
+ raise Exception('invalid ciphertext: invalid ephemeral pubkey') from e
+ ecdh_key = (ephemeral_pubkey * ec_privkey.secret_scalar).get_public_key_bytes(compressed=True)
+ key = hashlib.sha512(ecdh_key).digest()
+ iv, key_e, key_m = key[0:16], key[16:32], key[32:]
+ if mac != hmac_oneshot(key_m, encrypted[:-32], hashlib.sha256):
+ raise InvalidPassword()
+ return aes_decrypt_with_iv(key_e, iv, ciphertext)
+
+
+def get_ecdh(priv: bytes, pub: bytes) -> bytes:
+ pt = ecc.ECPubkey(pub) * ecc.string_to_number(priv)
+ return sha256(pt.get_public_key_bytes())
+
+def privkey_to_pubkey(priv: bytes) -> bytes:
+ return ecc.ECPrivkey(priv[:32]).get_public_key_bytes()
diff --git a/electrum/currencies.json b/electrum/currencies.json
new file mode 100644
index 000000000000..b68a54c59eaa
--- /dev/null
+++ b/electrum/currencies.json
@@ -0,0 +1,907 @@
+{
+ "Bit2C": [
+ "ILS"
+ ],
+ "BitFinex": [
+ "EUR",
+ "GBP",
+ "JPY",
+ "TRY",
+ "USD",
+ "UST"
+ ],
+ "BitFlyer": [
+ "JPY"
+ ],
+ "BitPay": [
+ "AED",
+ "AFN",
+ "ALL",
+ "AMD",
+ "ANG",
+ "AOA",
+ "APE",
+ "ARS",
+ "AUD",
+ "AWG",
+ "AZN",
+ "BAM",
+ "BBD",
+ "BCH",
+ "BDT",
+ "BGN",
+ "BHD",
+ "BIF",
+ "BMD",
+ "BND",
+ "BOB",
+ "BRL",
+ "BSD",
+ "BTC",
+ "BTN",
+ "BWP",
+ "BYN",
+ "BZD",
+ "CAD",
+ "CDF",
+ "CHF",
+ "CLF",
+ "CLP",
+ "CNY",
+ "COP",
+ "CRC",
+ "CUP",
+ "CVE",
+ "CZK",
+ "DAI",
+ "DJF",
+ "DKK",
+ "DOP",
+ "DZD",
+ "EGP",
+ "ETB",
+ "ETH",
+ "EUR",
+ "FJD",
+ "FKP",
+ "GBP",
+ "GEL",
+ "GHS",
+ "GIP",
+ "GMD",
+ "GNF",
+ "GTQ",
+ "GYD",
+ "HKD",
+ "HNL",
+ "HRK",
+ "HTG",
+ "HUF",
+ "IDR",
+ "ILS",
+ "INR",
+ "IQD",
+ "IRR",
+ "ISK",
+ "JEP",
+ "JMD",
+ "JOD",
+ "JPY",
+ "KES",
+ "KGS",
+ "KHR",
+ "KMF",
+ "KPW",
+ "KRW",
+ "KWD",
+ "KYD",
+ "KZT",
+ "LAK",
+ "LBP",
+ "LKR",
+ "LRD",
+ "LSL",
+ "LTC",
+ "LYD",
+ "MAD",
+ "MDL",
+ "MGA",
+ "MKD",
+ "MMK",
+ "MNT",
+ "MOP",
+ "MRU",
+ "MUR",
+ "MVR",
+ "MWK",
+ "MXN",
+ "MYR",
+ "MZN",
+ "NAD",
+ "NGN",
+ "NIO",
+ "NOK",
+ "NPR",
+ "NZD",
+ "OMR",
+ "PAB",
+ "PAX",
+ "PEN",
+ "PGK",
+ "PHP",
+ "PKR",
+ "PLN",
+ "PYG",
+ "QAR",
+ "RON",
+ "RSD",
+ "RUB",
+ "RWF",
+ "SAR",
+ "SBD",
+ "SCR",
+ "SDG",
+ "SEK",
+ "SGD",
+ "SHP",
+ "SLL",
+ "SOS",
+ "SRD",
+ "STN",
+ "SVC",
+ "SYP",
+ "SZL",
+ "THB",
+ "TJS",
+ "TMT",
+ "TND",
+ "TOP",
+ "TRY",
+ "TTD",
+ "TWD",
+ "TZS",
+ "UAH",
+ "UGX",
+ "USD",
+ "UYU",
+ "UZS",
+ "VES",
+ "VND",
+ "VUV",
+ "WST",
+ "XAF",
+ "XAG",
+ "XAU",
+ "XCD",
+ "XOF",
+ "XPF",
+ "XRP",
+ "YER",
+ "ZAR",
+ "ZMW",
+ "ZWL"
+ ],
+ "BitStamp": [
+ "USD",
+ "EUR",
+ "GBP"
+ ],
+ "Bitbank": [
+ "JPY"
+ ],
+ "Bitso": [
+ "MXN"
+ ],
+ "Bitvalor": [
+ "BRL"
+ ],
+ "BlockchainInfo": [
+ "ARS",
+ "AUD",
+ "BRL",
+ "CAD",
+ "CHF",
+ "CLP",
+ "CNY",
+ "CZK",
+ "DKK",
+ "EUR",
+ "GBP",
+ "HKD",
+ "HRK",
+ "HUF",
+ "INR",
+ "ISK",
+ "JPY",
+ "KRW",
+ "NZD",
+ "PLN",
+ "RON",
+ "RUB",
+ "SEK",
+ "SGD",
+ "THB",
+ "TRY",
+ "TWD",
+ "USD"
+ ],
+ "Bylls": [
+ "CAD"
+ ],
+ "CoinCap": [
+ "USD"
+ ],
+ "CoinDesk": [
+ "AED",
+ "AFN",
+ "ALL",
+ "AMD",
+ "ANG",
+ "AOA",
+ "ARS",
+ "AUD",
+ "AWG",
+ "AZN",
+ "BAM",
+ "BBD",
+ "BDT",
+ "BGN",
+ "BHD",
+ "BIF",
+ "BMD",
+ "BND",
+ "BOB",
+ "BRL",
+ "BSD",
+ "BTC",
+ "BTN",
+ "BWP",
+ "BYR",
+ "BZD",
+ "CAD",
+ "CDF",
+ "CHF",
+ "CLF",
+ "CLP",
+ "CNY",
+ "COP",
+ "CRC",
+ "CUC",
+ "CUP",
+ "CVE",
+ "CZK",
+ "DJF",
+ "DKK",
+ "DOP",
+ "DZD",
+ "EGP",
+ "ERN",
+ "ETB",
+ "EUR",
+ "FJD",
+ "FKP",
+ "GBP",
+ "GEL",
+ "GGP",
+ "GHS",
+ "GIP",
+ "GMD",
+ "GNF",
+ "GTQ",
+ "GYD",
+ "HKD",
+ "HNL",
+ "HRK",
+ "HTG",
+ "HUF",
+ "IDR",
+ "ILS",
+ "IMP",
+ "INR",
+ "IQD",
+ "IRR",
+ "ISK",
+ "JEP",
+ "JMD",
+ "JOD",
+ "JPY",
+ "KES",
+ "KGS",
+ "KHR",
+ "KMF",
+ "KPW",
+ "KRW",
+ "KWD",
+ "KYD",
+ "KZT",
+ "LAK",
+ "LBP",
+ "LKR",
+ "LRD",
+ "LSL",
+ "LYD",
+ "MAD",
+ "MDL",
+ "MGA",
+ "MKD",
+ "MMK",
+ "MNT",
+ "MOP",
+ "MRU",
+ "MUR",
+ "MVR",
+ "MWK",
+ "MXN",
+ "MYR",
+ "MZN",
+ "NAD",
+ "NGN",
+ "NIO",
+ "NOK",
+ "NPR",
+ "NZD",
+ "OMR",
+ "PAB",
+ "PEN",
+ "PGK",
+ "PHP",
+ "PKR",
+ "PLN",
+ "PYG",
+ "QAR",
+ "RON",
+ "RSD",
+ "RUB",
+ "RWF",
+ "SAR",
+ "SBD",
+ "SCR",
+ "SDG",
+ "SEK",
+ "SGD",
+ "SHP",
+ "SLL",
+ "SOS",
+ "SRD",
+ "STD",
+ "STN",
+ "SVC",
+ "SYP",
+ "SZL",
+ "THB",
+ "TJS",
+ "TMT",
+ "TND",
+ "TOP",
+ "TRY",
+ "TTD",
+ "TWD",
+ "TZS",
+ "UAH",
+ "UGX",
+ "USD",
+ "UYU",
+ "UZS",
+ "VES",
+ "VND",
+ "VUV",
+ "WST",
+ "XAF",
+ "XAG",
+ "XAU",
+ "XBT",
+ "XCD",
+ "XDR",
+ "XOF",
+ "XPF",
+ "YER",
+ "ZAR",
+ "ZMW",
+ "ZWL"
+ ],
+ "CoinGecko": [
+ "AED",
+ "ARS",
+ "AUD",
+ "BCH",
+ "BDT",
+ "BHD",
+ "BMD",
+ "BNB",
+ "BRL",
+ "BTC",
+ "CAD",
+ "CHF",
+ "CLP",
+ "CNY",
+ "CZK",
+ "DKK",
+ "DOT",
+ "EOS",
+ "ETH",
+ "EUR",
+ "GBP",
+ "GEL",
+ "HKD",
+ "HUF",
+ "IDR",
+ "ILS",
+ "INR",
+ "JPY",
+ "KRW",
+ "KWD",
+ "LKR",
+ "LTC",
+ "MMK",
+ "MXN",
+ "MYR",
+ "NGN",
+ "NOK",
+ "NZD",
+ "PHP",
+ "PKR",
+ "PLN",
+ "RUB",
+ "SAR",
+ "SEK",
+ "SGD",
+ "THB",
+ "TRY",
+ "TWD",
+ "UAH",
+ "USD",
+ "VEF",
+ "VND",
+ "XAG",
+ "XAU",
+ "XDR",
+ "XLM",
+ "XRP",
+ "YFI",
+ "ZAR"
+ ],
+ "Coinbase": [
+ "ABT",
+ "ACH",
+ "ACS",
+ "ACX",
+ "ADA",
+ "AED",
+ "AFN",
+ "AKT",
+ "ALL",
+ "AMD",
+ "AMP",
+ "ANG",
+ "ANT",
+ "AOA",
+ "APE",
+ "APT",
+ "ARB",
+ "ARS",
+ "ASM",
+ "AST",
+ "ATA",
+ "AUD",
+ "AVT",
+ "AWG",
+ "AXL",
+ "AXS",
+ "AZN",
+ "BAL",
+ "BAM",
+ "BAT",
+ "BBD",
+ "BCH",
+ "BDT",
+ "BGN",
+ "BHD",
+ "BIF",
+ "BIT",
+ "BLZ",
+ "BMD",
+ "BND",
+ "BNT",
+ "BOB",
+ "BRL",
+ "BSD",
+ "BSV",
+ "BTC",
+ "BTN",
+ "BWP",
+ "BYN",
+ "BYR",
+ "BZD",
+ "C98",
+ "CAD",
+ "CDF",
+ "CHF",
+ "CHZ",
+ "CLF",
+ "CLP",
+ "CLV",
+ "CNH",
+ "CNY",
+ "COP",
+ "COW",
+ "CRC",
+ "CRO",
+ "CRV",
+ "CTX",
+ "CUC",
+ "CUP",
+ "CVC",
+ "CVE",
+ "CVX",
+ "CZK",
+ "DAI",
+ "DAR",
+ "DDX",
+ "DIA",
+ "DJF",
+ "DKK",
+ "DNT",
+ "DOP",
+ "DOT",
+ "DYP",
+ "DZD",
+ "EEK",
+ "EGP",
+ "ELA",
+ "ENJ",
+ "ENS",
+ "EOS",
+ "ERN",
+ "ETB",
+ "ETC",
+ "ETH",
+ "EUR",
+ "FET",
+ "FIL",
+ "FIS",
+ "FJD",
+ "FKP",
+ "FLR",
+ "FOX",
+ "FTM",
+ "GAL",
+ "GBP",
+ "GEL",
+ "GFI",
+ "GGP",
+ "GHS",
+ "GIP",
+ "GLM",
+ "GMD",
+ "GMT",
+ "GNF",
+ "GNO",
+ "GNT",
+ "GRT",
+ "GST",
+ "GTC",
+ "GTQ",
+ "GYD",
+ "HFT",
+ "HKD",
+ "HNL",
+ "HNT",
+ "HRK",
+ "HTG",
+ "HUF",
+ "ICP",
+ "IDR",
+ "ILS",
+ "ILV",
+ "IMP",
+ "IMX",
+ "INJ",
+ "INR",
+ "INV",
+ "IQD",
+ "IRR",
+ "ISK",
+ "JEP",
+ "JMD",
+ "JOD",
+ "JPY",
+ "JTO",
+ "JUP",
+ "KES",
+ "KGS",
+ "KHR",
+ "KMF",
+ "KNC",
+ "KPW",
+ "KRL",
+ "KRW",
+ "KSM",
+ "KWD",
+ "KYD",
+ "KZT",
+ "LAK",
+ "LBP",
+ "LCX",
+ "LDO",
+ "LIT",
+ "LKR",
+ "LPT",
+ "LRC",
+ "LRD",
+ "LSL",
+ "LTC",
+ "LTL",
+ "LVL",
+ "LYD",
+ "MAD",
+ "MDL",
+ "MDT",
+ "MGA",
+ "MIR",
+ "MKD",
+ "MKR",
+ "MLN",
+ "MMK",
+ "MNT",
+ "MOP",
+ "MPL",
+ "MRO",
+ "MRU",
+ "MTL",
+ "MUR",
+ "MVR",
+ "MWK",
+ "MXC",
+ "MXN",
+ "MYR",
+ "MZN",
+ "NAD",
+ "NCT",
+ "NGN",
+ "NIO",
+ "NKN",
+ "NMR",
+ "NOK",
+ "NPR",
+ "NZD",
+ "OGN",
+ "OMG",
+ "OMR",
+ "ORN",
+ "OXT",
+ "PAB",
+ "PAX",
+ "PEN",
+ "PGK",
+ "PHP",
+ "PKR",
+ "PLA",
+ "PLN",
+ "PLU",
+ "PNG",
+ "POL",
+ "PRO",
+ "PRQ",
+ "PYG",
+ "PYR",
+ "QAR",
+ "QNT",
+ "RAD",
+ "RAI",
+ "RBN",
+ "REN",
+ "REP",
+ "REQ",
+ "RGT",
+ "RLC",
+ "RLY",
+ "RON",
+ "RPL",
+ "RSD",
+ "RUB",
+ "RWF",
+ "SAR",
+ "SBD",
+ "SCR",
+ "SDG",
+ "SEI",
+ "SEK",
+ "SGD",
+ "SHP",
+ "SKK",
+ "SKL",
+ "SLL",
+ "SNT",
+ "SNX",
+ "SOL",
+ "SOS",
+ "SPA",
+ "SRD",
+ "SSP",
+ "STD",
+ "STG",
+ "STX",
+ "SUI",
+ "SVC",
+ "SYN",
+ "SYP",
+ "SZL",
+ "THB",
+ "TIA",
+ "TJS",
+ "TMM",
+ "TMT",
+ "TND",
+ "TOP",
+ "TRB",
+ "TRU",
+ "TRY",
+ "TTD",
+ "TVK",
+ "TWD",
+ "TZS",
+ "UAH",
+ "UGX",
+ "UMA",
+ "UNI",
+ "UPI",
+ "USD",
+ "UST",
+ "UYU",
+ "UZS",
+ "VEF",
+ "VES",
+ "VET",
+ "VGX",
+ "VND",
+ "VUV",
+ "WST",
+ "XAF",
+ "XAG",
+ "XAU",
+ "XBC",
+ "XCD",
+ "XCN",
+ "XDR",
+ "XLM",
+ "XOF",
+ "XPD",
+ "XPF",
+ "XPT",
+ "XRP",
+ "XTZ",
+ "XYO",
+ "YER",
+ "YFI",
+ "ZAR",
+ "ZEC",
+ "ZEN",
+ "ZMK",
+ "ZMW",
+ "ZRX",
+ "ZWD"
+ ],
+ "CointraderMonitor": [
+ "BRL"
+ ],
+ "Kraken": [
+ "CAD",
+ "EUR",
+ "GBP",
+ "JPY",
+ "USD"
+ ],
+ "Walltime": [
+ "BRL"
+ ],
+ "Yadio": [
+ "AED",
+ "ALL",
+ "ANG",
+ "AOA",
+ "ARS",
+ "AUD",
+ "AZN",
+ "BDT",
+ "BGN",
+ "BHD",
+ "BIF",
+ "BMD",
+ "BOB",
+ "BRL",
+ "BTC",
+ "BWP",
+ "BYN",
+ "BZD",
+ "CAD",
+ "CDF",
+ "CHF",
+ "CLP",
+ "CNY",
+ "COP",
+ "CRC",
+ "CUP",
+ "CZK",
+ "DJF",
+ "DKK",
+ "DOP",
+ "DZD",
+ "EGP",
+ "ETB",
+ "EUR",
+ "GBP",
+ "GEL",
+ "GHS",
+ "GNF",
+ "GTQ",
+ "HKD",
+ "HNL",
+ "HUF",
+ "IDR",
+ "ILS",
+ "INR",
+ "IRR",
+ "IRT",
+ "ISK",
+ "JMD",
+ "JOD",
+ "JPY",
+ "KES",
+ "KGS",
+ "KRW",
+ "KZT",
+ "LBP",
+ "LKR",
+ "MAD",
+ "MGA",
+ "MLC",
+ "MRU",
+ "MWK",
+ "MXN",
+ "MYR",
+ "NAD",
+ "NGN",
+ "NIO",
+ "NOK",
+ "NPR",
+ "NZD",
+ "PAB",
+ "PEN",
+ "PHP",
+ "PKR",
+ "PLN",
+ "PYG",
+ "QAR",
+ "RON",
+ "RSD",
+ "RUB",
+ "RWF",
+ "SAR",
+ "SEK",
+ "SGD",
+ "SYP",
+ "THB",
+ "TND",
+ "TRY",
+ "TTD",
+ "TWD",
+ "TZS",
+ "UAH",
+ "UGX",
+ "USD",
+ "UYU",
+ "UZS",
+ "VES",
+ "VND",
+ "XAF",
+ "XAG",
+ "XAU",
+ "XOF",
+ "XPT",
+ "ZAR",
+ "ZMW"
+ ],
+ "Zaif": [
+ "JPY"
+ ]
+}
\ No newline at end of file
diff --git a/electrum/daemon.py b/electrum/daemon.py
new file mode 100644
index 000000000000..0a5273129912
--- /dev/null
+++ b/electrum/daemon.py
@@ -0,0 +1,780 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2015 Thomas Voegtlin
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+import asyncio
+import ast
+import errno
+import os
+import time
+import traceback
+import sys
+import threading
+from typing import Dict, Optional, Tuple, Callable, Union, Sequence, Mapping, TYPE_CHECKING
+from base64 import b64decode, b64encode
+import json
+import socket
+import stat
+
+import aiohttp
+from aiohttp import web, client_exceptions
+from aiorpcx import ignore_after
+
+from . import util
+from .network import Network
+from .util import (
+ json_decode, to_bytes, to_string, profiler, standardize_path, constant_time_compare, InvalidPassword,
+ log_exceptions, randrange, OldTaskGroup, UserFacingException, JsonRPCError, os_chmod
+)
+from .wallet import Wallet, Abstract_Wallet
+from .storage import WalletStorage
+from .wallet_db import WalletDB, WalletUnfinished
+from .commands import known_commands, Commands
+from .simple_config import SimpleConfig
+from .exchange_rate import FxThread
+from .logging import get_logger, Logger
+from . import GuiImportError
+from .plugin import run_hook, Plugins
+
+if TYPE_CHECKING:
+ from electrum import gui
+
+
+_logger = get_logger(__name__)
+
+
+class DaemonNotRunning(Exception):
+ pass
+
+
+def get_rpcsock_defaultpath(config: SimpleConfig):
+ return os.path.join(config.path, 'daemon_rpc_socket')
+
+
+def get_rpcsock_default_type(config: SimpleConfig):
+ if config.RPC_PORT:
+ return 'tcp'
+ # Use unix domain sockets when available,
+ # with the extra paranoia that in case windows "implements" them,
+ # we want to test it before making it the default there.
+ if hasattr(socket, 'AF_UNIX') and sys.platform != 'win32':
+ return 'unix'
+ return 'tcp'
+
+
+def get_lockfile(config: SimpleConfig):
+ return os.path.join(config.path, 'daemon')
+
+
+def remove_lockfile(lockfile):
+ os.unlink(lockfile)
+
+
+def get_file_descriptor(config: SimpleConfig):
+ '''Tries to create the lockfile, using O_EXCL to
+ prevent races. If it succeeds, it returns the FD.
+ Otherwise, try and connect to the server specified in the lockfile.
+ If this succeeds, the server is returned. Otherwise, remove the
+ lockfile and try again.'''
+ lockfile = get_lockfile(config)
+ while True:
+ try:
+ return os.open(lockfile, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
+ except OSError:
+ pass
+ try:
+ request(config, 'ping')
+ return None
+ except DaemonNotRunning:
+ # Couldn't connect; remove lockfile and try again.
+ remove_lockfile(lockfile)
+
+
+def request(config: SimpleConfig, endpoint, args=(), timeout: Union[float, int] = 60):
+ lockfile = get_lockfile(config)
+ for attempt in range(5):
+ create_time = None # type: Optional[float | int]
+ path = None
+ try:
+ with open(lockfile) as f:
+ socktype, address, create_time = ast.literal_eval(f.read())
+ int(create_time) # raise if not numeric
+ if socktype == 'unix':
+ path = address
+ (host, port) = "127.0.0.1", 0
+ # We still need a host and port for e.g. HTTP Host header
+ elif socktype == 'tcp':
+ (host, port) = address
+ else:
+ raise Exception(f"corrupt lockfile; socktype={socktype!r}")
+ except Exception:
+ raise DaemonNotRunning()
+ rpc_user, rpc_password = get_rpc_credentials(config)
+ server_url = 'http://%s:%d' % (host, port)
+ auth = aiohttp.BasicAuth(login=rpc_user, password=rpc_password)
+ loop = util.get_asyncio_loop()
+
+ async def request_coroutine(
+ *, socktype=socktype, path=path, auth=auth, server_url=server_url, endpoint=endpoint,
+ ):
+ if socktype == 'unix':
+ connector = aiohttp.UnixConnector(path=path)
+ elif socktype == 'tcp':
+ connector = None # This will transform into TCP.
+ else:
+ raise Exception(f"impossible socktype ({socktype!r})")
+ async with aiohttp.ClientSession(auth=auth, connector=connector) as session:
+ 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)
+ except aiohttp.client_exceptions.ClientConnectorError as e:
+ _logger.info(f"failed to connect to JSON-RPC server {e}")
+ # We cannot communicate with the daemon.
+ # If daemon's creation time is very recent, it might still be starting up.
+ # In any other case, we raise: - too old create_time means daemon is likely dead,
+ # - create_time in future means our clock cannot be trusted.
+ if not (create_time <= time.time() <= create_time + 1.0):
+ raise DaemonNotRunning()
+ # Sleep a bit and try again; daemon might have just been started
+ time.sleep(1.0)
+ # how did we even get here?! the clock must be going haywire.
+ _logger.error(f"Failed to connect to JSON-RPC server. Exhausted all attempts.")
+ raise DaemonNotRunning()
+
+
+def wait_until_daemon_becomes_ready(*, config: SimpleConfig, timeout=5) -> bool:
+ t0 = time.monotonic()
+ while True:
+ if time.monotonic() > t0 + timeout:
+ return False # timeout
+ try:
+ request(config, 'ping')
+ return True # success
+ except DaemonNotRunning:
+ time.sleep(0.05)
+ continue
+
+
+def get_rpc_credentials(config: SimpleConfig) -> Tuple[str, str]:
+ rpc_user = config.RPC_USERNAME or None
+ rpc_password = config.RPC_PASSWORD or None
+ # note: we explicitly forbid empty/unset password, and will generate one now instead
+ if rpc_user is None or rpc_password is None:
+ rpc_user = 'user'
+ bits = 128
+ nbytes = bits // 8 + (bits % 8 > 0)
+ pw_int = randrange(pow(2, bits))
+ pw_b64 = b64encode(
+ pw_int.to_bytes(nbytes, 'big'), b'-_')
+ rpc_password = to_string(pw_b64, 'ascii')
+ config.RPC_USERNAME = rpc_user
+ config.RPC_PASSWORD = rpc_password
+ return rpc_user, rpc_password
+
+
+class AuthenticationError(Exception):
+ pass
+
+
+class AuthenticationInvalidOrMissing(AuthenticationError):
+ pass
+
+
+class AuthenticationCredentialsInvalid(AuthenticationError):
+ pass
+
+
+class AuthenticatedServer(Logger):
+
+ 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._methods = {} # type: Dict[str, Callable]
+
+ def register_method(self, name: str, f):
+ assert name not in self._methods, f"name collision for {name}"
+ self._methods[name] = f
+
+ async def authenticate(self, headers):
+ if not self.rpc_password:
+ raise Exception('Server RPC password is unset. This should not happen.')
+ auth_string = headers.get('Authorization', None)
+ if auth_string is None:
+ raise AuthenticationInvalidOrMissing('CredentialsMissing')
+ basic, _, encoded = auth_string.partition(' ')
+ if basic != 'Basic':
+ raise AuthenticationInvalidOrMissing('UnsupportedType')
+ encoded = to_bytes(encoded, 'utf8')
+ credentials = to_string(b64decode(encoded, validate=True), 'utf8')
+ username, _, password = credentials.partition(':')
+ if not (constant_time_compare(username, self.rpc_user)
+ and constant_time_compare(password, self.rpc_password)):
+ await asyncio.sleep(0.050)
+ raise AuthenticationCredentialsInvalid('Invalid Credentials')
+
+ async def handle(self, request):
+ async with self.auth_lock:
+ try:
+ await self.authenticate(request.headers)
+ except AuthenticationInvalidOrMissing:
+ return web.Response(headers={"WWW-Authenticate": "Basic realm=Electrum"},
+ text='Unauthorized', status=401)
+ except AuthenticationCredentialsInvalid:
+ return web.Response(text='Forbidden', status=403)
+ 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,
+ 'jsonrpc': '2.0',
+ }
+ try:
+ if isinstance(params, dict):
+ response['result'] = await f(**params)
+ else:
+ response['result'] = await f(*params)
+ except UserFacingException as e:
+ response['error'] = {
+ 'code': JsonRPCError.Codes.USERFACING,
+ 'message': str(e),
+ }
+ except BaseException as e:
+ self.logger.exception("internal error while executing RPC")
+ response['error'] = {
+ 'code': JsonRPCError.Codes.INTERNAL,
+ 'message': "internal error while executing RPC",
+ 'data': {
+ "exception": repr(e),
+ "traceback": "".join(traceback.format_exception(e)),
+ },
+ }
+ return web.json_response(response)
+
+
+class CommandsServer(AuthenticatedServer):
+
+ def __init__(self, daemon: 'Daemon', fd, *, only_minimal_jsonrpc: bool):
+ rpc_user, rpc_password = get_rpc_credentials(daemon.config)
+ AuthenticatedServer.__init__(self, rpc_user, rpc_password)
+ self.daemon = daemon
+ self.fd = fd
+ self._only_minimal_jsonrpc = only_minimal_jsonrpc
+ self.config = daemon.config
+ sockettype = self.config.RPC_SOCKET_TYPE
+ self.socktype = sockettype if sockettype != 'auto' else get_rpcsock_default_type(self.config)
+ self.sockpath = self.config.RPC_SOCKET_FILEPATH or get_rpcsock_defaultpath(self.config)
+ self.host = self.config.RPC_HOST
+ self.port = self.config.RPC_PORT
+ self.cmd_runner = Commands(config=self.config, network=self.daemon.network, daemon=self.daemon)
+ self.app = web.Application()
+ self.app.router.add_post("/", self.handle)
+ # First add always-enabled commands that are also available for "minimal" rpc server.
+ # - "ping" RPC is needed for the lockfile fd to work.
+ self.register_method('ping', self.ping)
+ # - "gui" RPC is needed for URI handling. (TODO restrict further: disallow opening arbitrary file paths)
+ self.register_method('gui', self.gui)
+ # Add other commands:
+ if not only_minimal_jsonrpc:
+ for cmdname in known_commands:
+ self.register_method(cmdname, getattr(self.cmd_runner, cmdname))
+ self.register_method('run_cmdline', self.run_cmdline)
+
+ def _socket_config_str(self) -> str:
+ if self.socktype == 'unix':
+ return f""
+ elif self.socktype == 'tcp':
+ return f""
+ else:
+ raise Exception(f"unknown socktype '{self.socktype!r}'")
+
+ async def run(self):
+ self.runner = web.AppRunner(self.app)
+ await self.runner.setup()
+ if self.socktype == 'unix':
+ site = web.UnixSite(self.runner, self.sockpath)
+ elif self.socktype == 'tcp':
+ site = web.TCPSite(self.runner, self.host, self.port)
+ else:
+ raise Exception(f"unknown socktype '{self.socktype!r}'")
+ try:
+ await site.start()
+ except Exception as e:
+ raise Exception(f"failed to start CommandsServer at {self._socket_config_str()}. got exc: {e!r}") from None
+ # now server has started.
+ if self.socktype == 'unix':
+ # set restrictive permissions on unix domain socket.
+ # FIXME race? we are late. should set this during socket-file creation but aiohttp API does not let us.
+ os_chmod(self.sockpath, stat.S_IREAD | stat.S_IWRITE)
+ # write server conn details into lockfile fd
+ if self.socktype == 'unix':
+ addr = self.sockpath
+ elif self.socktype == 'tcp':
+ socket = site._server.sockets[0]
+ addr = socket.getsockname()
+ else:
+ raise Exception(f"impossible socktype ({self.socktype!r})")
+ os.write(self.fd, bytes(repr((self.socktype, addr, time.time())), 'utf8'))
+ os.close(self.fd)
+ self.logger.info(
+ f"now running and listening. socktype={self.socktype}, addr={addr}. "
+ f"only_minimal_jsonrpc={self._only_minimal_jsonrpc}")
+
+ async def ping(self):
+ return True
+
+ async def gui(self, config_options):
+ # note: "config_options" is coming from the short-lived CLI-invocation,
+ # while self.config is the config of the long-lived daemon process.
+ # "config_options" should have priority.
+ if self.daemon.gui_object:
+ if hasattr(self.daemon.gui_object, 'new_window'):
+ if config_options.get(SimpleConfig.NETWORK_OFFLINE.key()) and not self.config.NETWORK_OFFLINE:
+ raise UserFacingException(
+ "error: current GUI is running online, so it cannot open a new wallet offline.")
+ path = config_options.get('wallet_path') or self.config.get_wallet_path()
+ self.daemon.gui_object.new_window(path, config_options.get('url'))
+ return True
+ else:
+ raise UserFacingException("error: current GUI does not support multiple windows")
+ else:
+ raise UserFacingException("error: Electrum is running in daemon mode. Please stop the daemon first.")
+
+ async def run_cmdline(self, config_options):
+ cmdname = config_options['cmd']
+ cmd = known_commands.get(cmdname)
+ if not cmd:
+ return f"unknown command: {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 or 'wallet' in cmd.options:
+ wallet_path = config_options.get('wallet_path')
+ if len(self.daemon._wallets) > 1 and wallet_path is None:
+ raise UserFacingException("error: wallet not specified")
+ kwargs['wallet_path'] = wallet_path
+ func = getattr(self.cmd_runner, cmd.name)
+ # execute requested command now. note: cmd can raise, the caller (self.handle) will wrap it.
+ result = await func(*args, **kwargs)
+ return result
+
+
+class Daemon(Logger):
+
+ network: Optional[Network] = None
+ gui_object: Optional['gui.BaseElectrumGui'] = None
+
+ @profiler
+ def __init__(
+ self,
+ config: SimpleConfig,
+ fd=None,
+ *,
+ listen_jsonrpc: bool = True,
+ only_minimal_jsonrpc: bool = True,
+ start_network: bool = True, # setting to False allows customising network settings before starting it
+ ):
+ Logger.__init__(self)
+ self.config = config
+ self.listen_jsonrpc = listen_jsonrpc
+ 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._plugins = None # type: Optional[Plugins]
+ self.asyncio_loop = util.get_asyncio_loop()
+ if not self.config.NETWORK_OFFLINE:
+ self.network = Network(config, daemon=self)
+ self.fx = FxThread(config=config)
+ # wallet_key -> wallet
+ self._wallets = {} # type: Dict[str, Abstract_Wallet]
+ self._wallet_lock = threading.RLock()
+
+ self._stop_entered = False
+ self._stopping_soon_or_errored = threading.Event()
+ self._stopped_event = threading.Event()
+
+ self.taskgroup = OldTaskGroup()
+ asyncio.run_coroutine_threadsafe(self._run(), self.asyncio_loop)
+ if start_network and self.network:
+ self.start_network()
+ # Setup commands server
+ self.commands_server = None
+ if listen_jsonrpc:
+ self.commands_server = CommandsServer(self, fd, only_minimal_jsonrpc=only_minimal_jsonrpc)
+ asyncio.run_coroutine_threadsafe(self.taskgroup.spawn(self.commands_server.run()), self.asyncio_loop)
+
+ @log_exceptions
+ async def _run(self):
+ self.logger.info("starting taskgroup.")
+ try:
+ async with self.taskgroup as group:
+ await group.spawn(asyncio.Event().wait) # run forever (until cancel)
+ except Exception as e:
+ self.logger.exception("taskgroup died.")
+ util.send_exception_to_crash_reporter(e)
+ finally:
+ self.logger.info("taskgroup stopped.")
+ # note: we could just "await self.stop()", but in that case GUI users would
+ # not see the exception (especially if the GUI did not start yet).
+ self._stopping_soon_or_errored.set()
+
+ def start_network(self):
+ self.logger.info(f"starting network.")
+ assert not self.config.NETWORK_OFFLINE
+ assert self.network
+ self.network.start(jobs=[self.fx.run])
+ # prepare lightning functionality, also load channel db early
+ if self.config.LIGHTNING_USE_GOSSIP:
+ self.network.start_gossip()
+
+ @staticmethod
+ def _wallet_key_from_path(path) -> str:
+ """This does stricter path standardization than 'standardize_path'.
+ It is used for keying the _wallets dict,
+ but MUST NOT be used as a *path* for the actual filesystem operations. (see #8495)
+ """
+ path = standardize_path(path)
+ # The extra normalisation makes it even harder to open the same wallet file multiple times simultaneously.
+ # - "realpath" resolves symlinks:
+ # note: the path returned by realpath has been observed NOT to work for FS operations!
+ # (e.g. for Cryptomator WinFSP/FUSE mounts, see #8495).
+ # It is okay for us to use it for computing a canonical wallet *key*, but cannot be used as a path!
+ try:
+ path = os.path.realpath(path, strict=False)
+ except OSError as e: # see #10182
+ _logger.warning(f"could not parse {path!r}: {e!r}")
+ path = path
+ # - "normcase" does Windows-specific case and slash normalisation:
+ path = os.path.normcase(path)
+ # - prepend header to break usage of wallet keys as fs paths
+ header = "WALLETKEY-"
+ return header + str(path)
+
+ def with_wallet_lock(func):
+ def func_wrapper(self: 'Daemon', *args, **kwargs):
+ with self._wallet_lock:
+ return func(self, *args, **kwargs)
+ return func_wrapper
+
+ @with_wallet_lock
+ def load_wallet(
+ self,
+ path,
+ password: Optional[str],
+ *,
+ upgrade: bool = False,
+ force_check_password: bool = False,
+ ) -> Optional[Abstract_Wallet]:
+ """
+ force_check_password: if False, the password arg is only used if it needed to decrypt the storage.
+ if True, the password arg is always validated.
+ """
+ assert password != ''
+ path = standardize_path(path)
+ wallet_key = self._wallet_key_from_path(path)
+ # wizard will be launched if we return
+ if wallet := self._wallets.get(wallet_key):
+ if force_check_password:
+ wallet.check_password(password)
+ if self.config.get('wallet_path') is None:
+ self.config.CURRENT_WALLET = path
+ return wallet
+ wallet = self._load_wallet(
+ path, password, upgrade=upgrade, config=self.config, force_check_password=force_check_password)
+ if self.network:
+ wallet.start_network(self.network)
+ elif wallet.lnworker:
+ # in offline mode, we need to trigger callbacks
+ coro = wallet.lnworker.lnwatcher.trigger_callbacks(requires_synchronizer=False)
+ asyncio.run_coroutine_threadsafe(coro, self.asyncio_loop)
+ self.add_wallet(wallet)
+ if self.config.get('wallet_path') is None:
+ self.config.CURRENT_WALLET = path
+ self.update_recently_opened_wallets(path)
+ return wallet
+
+
+ @staticmethod
+ @profiler
+ def _load_wallet(
+ path,
+ password: Optional[str],
+ *,
+ upgrade: bool = False,
+ config: SimpleConfig,
+ force_check_password: bool = False, # if set, always validate password
+ ) -> Optional[Abstract_Wallet]:
+ path = standardize_path(path)
+ storage = WalletStorage(path, allow_partial_writes=config.WALLET_PARTIAL_WRITES)
+ if not storage.file_exists():
+ raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), path)
+ if storage.is_encrypted():
+ if not password:
+ raise InvalidPassword('No password given')
+ storage.decrypt(password)
+ # read data, pass it to db
+ db = WalletDB(storage.read(), storage=storage, upgrade=upgrade)
+ if db.get_action():
+ raise WalletUnfinished(db)
+ wallet = Wallet(db, config=config)
+ if force_check_password:
+ wallet.check_password(password)
+ return wallet
+
+ @with_wallet_lock
+ def add_wallet(self, wallet: Abstract_Wallet) -> None:
+ path = wallet.storage.get_path()
+ wallet_key = self._wallet_key_from_path(path)
+ self._wallets[wallet_key] = wallet
+ run_hook('daemon_wallet_loaded', self, wallet)
+
+ def get_wallet(self, path: str) -> Optional[Abstract_Wallet]:
+ wallet_key = self._wallet_key_from_path(path)
+ return self._wallets.get(wallet_key)
+
+ @with_wallet_lock
+ def get_wallets(self) -> Dict[str, Abstract_Wallet]:
+ return dict(self._wallets) # copy
+
+ def delete_wallet(self, path: str) -> bool:
+ self.stop_wallet(path)
+ if os.path.exists(path):
+ os.unlink(path)
+ self.update_recently_opened_wallets(path, remove=True)
+ if self.config.CURRENT_WALLET == path:
+ self.config.CURRENT_WALLET = None
+ return True
+ return False
+
+ def rename_wallet_file(self, old_path: str, new_path: str):
+ old_path = standardize_path(old_path)
+ new_path = standardize_path(new_path)
+ if os.path.exists(new_path):
+ raise ValueError("Wallet file already exists")
+ os.rename(old_path, new_path)
+ self.logger.debug(f'renamed wallet: {old_path} -> {new_path}')
+ self.update_recently_opened_wallets(old_path, remove=True)
+ if self.config.CURRENT_WALLET == old_path:
+ self.config.CURRENT_WALLET = new_path
+
+ def stop_wallet(self, path: str) -> bool:
+ """Returns True iff a wallet was found."""
+ assert util.get_running_loop() != util.get_asyncio_loop(), 'must not be called from asyncio thread'
+ fut = asyncio.run_coroutine_threadsafe(self._stop_wallet(path), self.asyncio_loop)
+ return fut.result()
+
+ @with_wallet_lock
+ async def _stop_wallet(self, path: str) -> bool:
+ """Returns True iff a wallet was found."""
+ path = standardize_path(path)
+ wallet_key = self._wallet_key_from_path(path)
+ wallet = self._wallets.pop(wallet_key, None)
+ if not wallet:
+ return False
+ await wallet.stop()
+ if self.config.get('wallet_path') is None:
+ wallet_paths = [w.storage.get_path() for w in self._wallets.values()
+ if w.storage and w.storage.get_path()]
+ if self.config.CURRENT_WALLET == path and wallet_paths:
+ self.config.CURRENT_WALLET = wallet_paths[0]
+ return True
+
+ def run_daemon(self):
+ if 'wallet_path' in self.config.cmdline_options:
+ self.logger.warning("Ignoring parameter 'wallet_path' for daemon. "
+ "Use the load_wallet command instead.")
+ # init plugins
+ self._plugins = Plugins(self.config, 'cmdline')
+ # block until we are stopping
+ try:
+ self._stopping_soon_or_errored.wait()
+ except KeyboardInterrupt:
+ self.logger.info("got KeyboardInterrupt")
+ # we either initiate shutdown now,
+ # or it has already been initiated (in which case this is a no-op):
+ self.logger.info("run_daemon is calling stop()")
+ asyncio.run_coroutine_threadsafe(self.stop(), self.asyncio_loop).result()
+ # wait until "stop" finishes:
+ self._stopped_event.wait()
+
+ async def stop(self):
+ if self._stop_entered:
+ return
+ self._stop_entered = True
+ self._stopping_soon_or_errored.set()
+ self.logger.info("stop() entered. initiating shutdown")
+ try:
+ if self.gui_object:
+ self.gui_object.stop()
+ self.logger.info("stopping all wallets")
+ async with OldTaskGroup() as group:
+ for k, wallet in self._wallets.items():
+ await group.spawn(wallet.stop())
+ self.logger.info("stopping network and taskgroup")
+ async with ignore_after(2):
+ async with OldTaskGroup() as group:
+ if self.network:
+ await group.spawn(self.network.stop(full_shutdown=True))
+ await group.spawn(self.taskgroup.cancel_remaining())
+ if self._plugins:
+ self.logger.info("stopping plugins")
+ self._plugins.stop()
+ async with ignore_after(1):
+ await self._plugins.stopped_event_async.wait()
+ finally:
+ if self.listen_jsonrpc:
+ self.logger.info("removing lockfile")
+ remove_lockfile(get_lockfile(self.config))
+ self.logger.info("stopped")
+ self._stopped_event.set()
+
+ def run_gui(self) -> None:
+ assert self.config
+ threading.current_thread().name = 'GUI'
+ gui_name = self.config.GUI_NAME
+ if gui_name in ['lite', 'classic']:
+ gui_name = 'qt'
+ self._plugins = Plugins(self.config, gui_name) # init plugins
+ self.logger.info(f'launching GUI: {gui_name}')
+ try:
+ try:
+ gui = __import__('electrum.gui.' + gui_name, fromlist=['electrum'])
+ except GuiImportError as e:
+ sys.exit(str(e))
+ self.gui_object = gui.ElectrumGui(config=self.config, daemon=self, plugins=self._plugins)
+ if not self._stop_entered:
+ self.gui_object.main()
+ else:
+ # If daemon.stop() was called before gui_object got created, stop gui now.
+ self.gui_object.stop()
+ except BaseException as e:
+ self.logger.error(f'GUI raised exception: {repr(e)}. shutting down.')
+ raise
+ finally:
+ # app will exit now
+ asyncio.run_coroutine_threadsafe(self.stop(), self.asyncio_loop).result()
+
+ @with_wallet_lock
+ def check_password_for_directory(self, *, old_password, new_password=None, wallet_dir: str) -> Tuple[bool, bool, list[str]]:
+ """Checks password against all wallets (in dir), returns whether they can be unified and whether they are already.
+ If new_password is not None, update all wallet passwords to new_password.
+ """
+ assert os.path.exists(wallet_dir), f"path {wallet_dir!r} does not exist"
+ succeeded = []
+ failed = []
+ is_unified = True
+ for filename in os.listdir(wallet_dir):
+ path = os.path.join(wallet_dir, filename)
+ path = standardize_path(path)
+ if not os.path.isfile(path):
+ continue
+ wallet = self.get_wallet(path)
+ # note: we only create a new wallet object if one was not loaded into the daemon already.
+ # This is to avoid having two wallet objects contending for the same file.
+ # Take care: this only works if the daemon knows about all wallet objects.
+ # if other code already has created a Wallet() for a file but did not tell the daemon,
+ # hard-to-understand bugs will follow...
+ if wallet is None:
+ try:
+ wallet = self._load_wallet(path, old_password, upgrade=True, config=self.config)
+ except util.InvalidPassword:
+ pass
+ except Exception:
+ self.logger.exception(f'failed to load wallet at {path!r}:')
+ if wallet is None:
+ failed.append(path)
+ continue
+ if not wallet.storage.is_encrypted():
+ is_unified = False
+ try:
+ try:
+ wallet.check_password(old_password)
+ old_password_real = old_password
+ except util.InvalidPassword:
+ wallet.check_password(None)
+ old_password_real = None
+ except Exception:
+ failed.append(path)
+ continue
+ if new_password:
+ self.logger.info(f'updating password for wallet: {path!r}')
+ wallet.update_password(old_password_real, new_password, encrypt_storage=True)
+ succeeded.append(path)
+
+ can_be_unified = failed == []
+ is_unified = can_be_unified and is_unified
+ return can_be_unified, is_unified, succeeded
+
+ @with_wallet_lock
+ def update_password_for_directory(
+ self,
+ *,
+ old_password,
+ new_password,
+ wallet_dir: Optional[str] = None,
+ ) -> bool:
+ """returns whether password is unified"""
+ if new_password is None:
+ # we opened a non-encrypted wallet
+ return False
+ if wallet_dir is None:
+ wallet_dir = os.path.dirname(self.config.get_wallet_path())
+ can_be_unified, is_unified, _ = self.check_password_for_directory(
+ old_password=old_password, new_password=None, wallet_dir=wallet_dir)
+ if not can_be_unified:
+ return False
+ if is_unified and old_password == new_password:
+ return True
+ self.check_password_for_directory(
+ old_password=old_password, new_password=new_password, wallet_dir=wallet_dir)
+ return True
+
+ def update_recently_opened_wallets(self, wallet_path, *, remove: bool = False):
+ recent = self.config.RECENTLY_OPEN_WALLET_FILES or []
+ if wallet_path in recent:
+ recent.remove(wallet_path)
+ if not remove:
+ recent.insert(0, wallet_path)
+ recent = [path for path in recent if os.path.exists(path)]
+ recent = recent[:5]
+ self.config.RECENTLY_OPEN_WALLET_FILES = recent
+ util.trigger_callback('recently_opened_wallets_update')
diff --git a/electrum/descriptor.py b/electrum/descriptor.py
new file mode 100644
index 000000000000..fcf2dd664295
--- /dev/null
+++ b/electrum/descriptor.py
@@ -0,0 +1,1078 @@
+# Copyright (c) 2017 Andrew Chow
+# Copyright (c) 2023 The Electrum developers
+# Distributed under the MIT software license, see the accompanying
+# file LICENCE or http://www.opensource.org/licenses/mit-license.php
+#
+# forked from https://github.com/bitcoin-core/HWI/blob/5f300d3dee7b317a6194680ad293eaa0962a3cc7/hwilib/descriptor.py
+#
+# Output Script Descriptors
+# See https://github.com/bitcoin/bitcoin/blob/master/doc/descriptors.md
+#
+# TODO allow xprv
+# TODO hardened derivation
+# TODO allow WIF privkeys
+# TODO impl ADDR descriptors
+# TODO impl RAW descriptors
+
+from binascii import unhexlify
+import enum
+from enum import Enum
+from typing import (
+ List,
+ NamedTuple,
+ Optional,
+ Tuple,
+ Sequence,
+ Mapping,
+ Set,
+ Union,
+)
+
+import electrum_ecc as ecc
+
+from .bip32 import convert_bip32_strpath_to_intpath, BIP32Node, KeyOriginInfo, BIP32_PRIME
+from . import bitcoin
+from .bitcoin import construct_script, opcodes, construct_witness, taproot_output_script
+from . import constants
+from .crypto import hash_160, sha256
+from . import segwit_addr
+
+
+MAX_TAPROOT_DEPTH = 128
+
+# we guess that signatures will be 72 bytes long
+# note: DER-encoded ECDSA signatures are 71 or 72 bytes in practice
+# See https://bitcoin.stackexchange.com/questions/77191/what-is-the-maximum-size-of-a-der-encoded-ecdsa-signature
+# We assume low S (as that is a bitcoin standardness rule).
+# We do not assume low R (even though the sigs we create conform), as external sigs,
+# e.g. from a hw signer cannot be expected to have a low R.
+DUMMY_DER_SIG = 72 * b"\x00"
+
+
+class ExpandedScripts:
+
+ def __init__(
+ self,
+ *,
+ output_script: bytes, # "scriptPubKey"
+ redeem_script: Optional[bytes] = None,
+ witness_script: Optional[bytes] = None,
+ scriptcode_for_sighash: Optional[bytes] = None
+ ):
+ self.output_script = output_script
+ self.redeem_script = redeem_script
+ self.witness_script = witness_script
+ self.scriptcode_for_sighash = scriptcode_for_sighash
+
+ @property
+ def scriptcode_for_sighash(self) -> Optional[bytes]:
+ if self._scriptcode_for_sighash:
+ return self._scriptcode_for_sighash
+ return self.witness_script or self.redeem_script or self.output_script
+
+ @scriptcode_for_sighash.setter
+ def scriptcode_for_sighash(self, value: Optional[bytes]):
+ self._scriptcode_for_sighash = value
+
+ def address(self, *, net=None) -> Optional[str]:
+ return bitcoin.script_to_address(self.output_script, net=net)
+
+
+class ScriptSolutionInner(NamedTuple):
+ witness_items: Optional[Sequence] = None
+
+
+class ScriptSolutionTop(NamedTuple):
+ witness: Optional[bytes] = None
+ script_sig: Optional[bytes] = None
+
+
+class MissingSolutionPiece(Exception): pass
+
+
+def PolyMod(c: int, val: int) -> int:
+ """
+ :meta private:
+ Function to compute modulo over the polynomial used for descriptor checksums
+ From: https://github.com/bitcoin/bitcoin/blob/master/src/script/descriptor.cpp
+ """
+ c0 = c >> 35
+ c = ((c & 0x7ffffffff) << 5) ^ val
+ if (c0 & 1):
+ c ^= 0xf5dee51989
+ if (c0 & 2):
+ c ^= 0xa9fdca3312
+ if (c0 & 4):
+ c ^= 0x1bab10e32d
+ if (c0 & 8):
+ c ^= 0x3706b1677a
+ if (c0 & 16):
+ c ^= 0x644d626ffd
+ return c
+
+
+_INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ "
+_INPUT_CHARSET_INV = {c: i for (i, c) in enumerate(_INPUT_CHARSET)}
+_CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
+
+def DescriptorChecksum(desc: str) -> str:
+ """
+ Compute the checksum for a descriptor
+
+ :param desc: The descriptor string to compute a checksum for
+ :return: A checksum
+ """
+ c = 1
+ cls = 0
+ clscount = 0
+ for ch in desc:
+ try:
+ pos = _INPUT_CHARSET_INV[ch]
+ except KeyError:
+ return ""
+ c = PolyMod(c, pos & 31)
+ cls = cls * 3 + (pos >> 5)
+ clscount += 1
+ if clscount == 3:
+ c = PolyMod(c, cls)
+ cls = 0
+ clscount = 0
+ if clscount > 0:
+ c = PolyMod(c, cls)
+ for j in range(0, 8):
+ c = PolyMod(c, 0)
+ c ^= 1
+
+ ret = [''] * 8
+ for j in range(0, 8):
+ ret[j] = _CHECKSUM_CHARSET[(c >> (5 * (7 - j))) & 31]
+ return ''.join(ret)
+
+def AddChecksum(desc: str) -> str:
+ """
+ Compute and attach the checksum for a descriptor
+
+ :param desc: The descriptor string to add a checksum to
+ :return: Descriptor with checksum
+ """
+ return desc + "#" + DescriptorChecksum(desc)
+
+
+class PubkeyProvider(object):
+ """
+ A public key expression in a descriptor.
+ Can contain the key origin info, the pubkey itself, and subsequent derivation paths for derivation from the pubkey
+ The pubkey can be a typical pubkey or an extended pubkey.
+ """
+ def __init__(
+ self,
+ origin: Optional['KeyOriginInfo'],
+ pubkey: str,
+ deriv_path: Optional[str]
+ ) -> None:
+ """
+ :param origin: The key origin if one is available
+ :param pubkey: The public key. Either a hex string or a serialized extended pubkey
+ :param deriv_path: Additional derivation path (suffix) if the pubkey is an extended pubkey
+ """
+ self.origin = origin
+ self.pubkey = pubkey
+ self.deriv_path = deriv_path
+ if deriv_path:
+ wildcard_count = deriv_path.count("*")
+ if wildcard_count > 1:
+ raise ValueError("only one wildcard(*) is allowed in a descriptor")
+ if wildcard_count == 1:
+ if deriv_path[-1] != "*":
+ raise ValueError("wildcard in descriptor only allowed in last position")
+ if deriv_path[0] != "/":
+ raise ValueError(f"deriv_path suffix must start with a '/'. got {deriv_path!r}")
+ # Make ExtendedKey from pubkey if it isn't hex
+ self.extkey = None
+ try:
+ unhexlify(self.pubkey)
+ # Is hex, normal pubkey
+ except Exception:
+ # Not hex, maybe xpub (but don't allow ypub/zpub)
+ self.extkey = BIP32Node.from_xkey(pubkey, allow_custom_headers=False)
+ if deriv_path and self.extkey is None:
+ raise ValueError("deriv_path suffix present for simple pubkey")
+
+ @classmethod
+ def parse(cls, s: str) -> 'PubkeyProvider':
+ """
+ Deserialize a key expression from the string into a ``PubkeyProvider``.
+
+ :param s: String containing the key expression
+ :return: A new ``PubkeyProvider`` containing the details given by ``s``
+ """
+ origin = None
+ deriv_path = None
+
+ if s[0] == "[":
+ end = s.index("]")
+ origin = KeyOriginInfo.from_string(s[1:end])
+ s = s[end + 1:]
+
+ pubkey = s
+ slash_idx = s.find("/")
+ if slash_idx != -1:
+ pubkey = s[:slash_idx]
+ deriv_path = s[slash_idx:]
+
+ return cls(origin, pubkey, deriv_path)
+
+ def to_string(self) -> str:
+ """
+ Serialize the pubkey expression to a string to be used in a descriptor
+
+ :return: The pubkey expression as a string
+ """
+ s = ""
+ if self.origin:
+ s += "[{}]".format(self.origin.to_string())
+ s += self.pubkey
+ if self.deriv_path:
+ s += self.deriv_path
+ return s
+
+ def get_pubkey_bytes(self, *, pos: Optional[int] = None) -> bytes:
+ if self.is_range() and pos is None:
+ raise ValueError("pos must be set for ranged descriptor")
+ # note: if not ranged, we ignore pos.
+ if self.extkey is not None:
+ compressed = True # bip32 implies compressed pubkeys
+ if self.deriv_path is None:
+ assert not self.is_range()
+ return self.extkey.eckey.get_public_key_bytes(compressed=compressed)
+ else:
+ path_str = self.deriv_path[1:]
+ if self.is_range():
+ assert path_str[-1] == "*"
+ path_str = path_str[:-1] + str(pos)
+ path = convert_bip32_strpath_to_intpath(path_str)
+ child_key = self.extkey.subkey_at_public_derivation(path)
+ return child_key.eckey.get_public_key_bytes(compressed=compressed)
+ else:
+ assert not self.is_range()
+ return unhexlify(self.pubkey)
+
+ def get_full_derivation_path(self, *, pos: Optional[int] = None) -> str:
+ """
+ Returns the full derivation path at the given position, including the origin
+ """
+ if self.is_range() and pos is None:
+ raise ValueError("pos must be set for ranged descriptor")
+ path = self.origin.get_derivation_path() if self.origin is not None else "m"
+ path += self.deriv_path if self.deriv_path is not None else ""
+ if path[-1] == "*":
+ path = path[:-1] + str(pos)
+ return path
+
+ def get_full_derivation_int_list(self, *, pos: Optional[int] = None) -> List[int]:
+ """
+ Returns the full derivation path as an integer list at the given position.
+ Includes the origin and master key fingerprint as an int
+ """
+ if self.is_range() and pos is None:
+ raise ValueError("pos must be set for ranged descriptor")
+ path: List[int] = self.origin.get_full_int_list() if self.origin is not None else []
+ path.extend(self.get_der_suffix_int_list(pos=pos))
+ return path
+
+ def get_der_suffix_int_list(self, *, pos: Optional[int] = None) -> List[int]:
+ if not self.deriv_path:
+ return []
+ der_suffix = self.deriv_path
+ assert (wc_count := der_suffix.count("*")) <= 1, wc_count
+ der_suffix = der_suffix.replace("*", str(pos))
+ return convert_bip32_strpath_to_intpath(der_suffix)
+
+ def __lt__(self, other: 'PubkeyProvider') -> bool:
+ return self.pubkey < other.pubkey
+
+ def is_range(self) -> bool:
+ if not self.deriv_path:
+ return False
+ if self.deriv_path[-1] == "*": # TODO hardened
+ return True
+ return False
+
+ def has_uncompressed_pubkey(self) -> bool:
+ if self.is_range(): # bip32 implies compressed
+ return False
+ return b"\x04" == self.get_pubkey_bytes()[:1]
+
+
+class Descriptor(object):
+ r"""
+ An abstract class for Descriptors themselves.
+ Descriptors can contain multiple :class:`PubkeyProvider`\ s and multiple ``Descriptor`` as subdescriptors.
+
+ Note: a significant portion of input validation logic is in parse_descriptor(),
+ maybe these checks should be moved to (or also done in) this class?
+ For example, sh() must be top-level, or segwit mandates compressed pubkeys,
+ or bare-multisig cannot have >3 pubkeys.
+ """
+ def __init__(
+ self,
+ pubkeys: List['PubkeyProvider'],
+ subdescriptors: List['Descriptor'],
+ name: str
+ ) -> None:
+ r"""
+ :param pubkeys: The :class:`PubkeyProvider`\ s that are part of this descriptor
+ :param subdescriptor: The ``Descriptor``\ s that are part of this descriptor
+ :param name: The name of the function for this descriptor
+ """
+ self.pubkeys = pubkeys
+ self.subdescriptors = subdescriptors
+ self.name = name
+
+ def to_string_no_checksum(self) -> str:
+ """
+ Serializes the descriptor as a string without the descriptor checksum
+
+ :return: The descriptor string
+ """
+ return "{}({}{})".format(
+ self.name,
+ ",".join([p.to_string() for p in self.pubkeys]),
+ self.subdescriptors[0].to_string_no_checksum() if len(self.subdescriptors) > 0 else ""
+ )
+
+ def to_string(self) -> str:
+ """
+ Serializes the descriptor as a string with the checksum
+
+ :return: The descriptor with a checksum
+ """
+ return AddChecksum(self.to_string_no_checksum())
+
+ def expand(self, *, pos: Optional[int] = None) -> "ExpandedScripts":
+ """
+ Returns the scripts for a descriptor at the given `pos` for ranged descriptors.
+ """
+ raise NotImplementedError("The Descriptor base class does not implement this method")
+
+ def _satisfy_inner(
+ self,
+ *,
+ sigdata: Mapping[bytes, bytes] = None, # pubkey -> sig
+ allow_dummy: bool = False,
+ ) -> ScriptSolutionInner:
+ raise NotImplementedError("The Descriptor base class does not implement this method")
+
+ def satisfy(
+ self,
+ *,
+ sigdata: Mapping[bytes, bytes] = None, # pubkey -> sig
+ allow_dummy: bool = False,
+ ) -> ScriptSolutionTop:
+ """Construct a witness and/or scriptSig to be used in a txin, to satisfy the bitcoin SCRIPT.
+
+ Raises MissingSolutionPiece if satisfaction is not yet possible due to e.g. missing a signature,
+ unless `allow_dummy` is set to True, in which case dummy data is used where needed (e.g. for size estimation).
+ """
+ assert not self.is_range()
+ sol = self._satisfy_inner(sigdata=sigdata, allow_dummy=allow_dummy)
+ witness = None
+ script_sig = None
+ if self.is_segwit():
+ witness = construct_witness(sol.witness_items)
+ else:
+ script_sig = construct_script(sol.witness_items)
+ return ScriptSolutionTop(
+ witness=witness,
+ script_sig=script_sig,
+ )
+
+ def get_satisfaction_progress(
+ self,
+ *,
+ sigdata: Mapping[bytes, bytes] = None, # pubkey -> sig
+ ) -> Tuple[int, int]:
+ """Returns (num_sigs_we_have, num_sigs_required) towards satisfying this script.
+ Besides signatures, later this can also consider hash-preimages.
+ """
+ assert not self.is_range()
+ nhave, nreq = 0, 0
+ for desc in self.subdescriptors:
+ a, b = desc.get_satisfaction_progress(sigdata=sigdata)
+ nhave += a
+ nreq += b
+ return nhave, nreq
+
+ def is_range(self) -> bool:
+ for pubkey in self.pubkeys:
+ if pubkey.is_range():
+ return True
+ for desc in self.subdescriptors:
+ if desc.is_range():
+ return True
+ return False
+
+ def is_segwit(self) -> bool:
+ return any([desc.is_segwit() for desc in self.subdescriptors])
+
+ def is_taproot(self) -> bool:
+ return False
+
+ def get_all_pubkeys(self) -> Set[bytes]:
+ """Returns set of pubkeys that appear at any level in this descriptor."""
+ assert not self.is_range()
+ all_pubkeys = set([p.get_pubkey_bytes() for p in self.pubkeys])
+ for desc in self.subdescriptors:
+ all_pubkeys |= desc.get_all_pubkeys()
+ return all_pubkeys
+
+ def get_simple_singlesig(self) -> Optional['Descriptor']:
+ """Returns innermost pk/pkh/wpkh descriptor, or None if we are not a simple singlesig.
+
+ note: besides pk,pkh,sh(wpkh),wpkh, overly complicated stuff such as sh(pk),wsh(sh(pkh),etc is also accepted
+ """
+ if len(self.subdescriptors) == 1:
+ return self.subdescriptors[0].get_simple_singlesig()
+ return None
+
+ def get_simple_multisig(self) -> Optional['MultisigDescriptor']:
+ """Returns innermost multi descriptor, or None if we are not a simple multisig."""
+ if len(self.subdescriptors) == 1:
+ return self.subdescriptors[0].get_simple_multisig()
+ return None
+
+ def to_legacy_electrum_script_type(self) -> str:
+ if isinstance(self, PKDescriptor):
+ return "p2pk"
+ elif isinstance(self, PKHDescriptor):
+ return "p2pkh"
+ elif isinstance(self, WPKHDescriptor):
+ return "p2wpkh"
+ elif isinstance(self, SHDescriptor) and isinstance(self.subdescriptors[0], WPKHDescriptor):
+ return "p2wpkh-p2sh"
+ elif isinstance(self, SHDescriptor) and isinstance(self.subdescriptors[0], MultisigDescriptor):
+ return "p2sh"
+ elif isinstance(self, WSHDescriptor) and isinstance(self.subdescriptors[0], MultisigDescriptor):
+ return "p2wsh"
+ elif (isinstance(self, SHDescriptor) and isinstance(self.subdescriptors[0], WSHDescriptor)
+ and isinstance(self.subdescriptors[0].subdescriptors[0], MultisigDescriptor)):
+ return "p2wsh-p2sh"
+ return "unknown"
+
+
+class PKDescriptor(Descriptor):
+ """
+ A descriptor for ``pk()`` descriptors
+ """
+ def __init__(
+ self,
+ pubkey: 'PubkeyProvider'
+ ) -> None:
+ """
+ :param pubkey: The :class:`PubkeyProvider` for this descriptor
+ """
+ super().__init__([pubkey], [], "pk")
+
+ def expand(self, *, pos: Optional[int] = None) -> "ExpandedScripts":
+ pubkey = self.pubkeys[0].get_pubkey_bytes(pos=pos)
+ script = construct_script([pubkey, opcodes.OP_CHECKSIG])
+ return ExpandedScripts(output_script=script)
+
+ def _satisfy_inner(self, *, sigdata=None, allow_dummy=False) -> ScriptSolutionInner:
+ if sigdata is None: sigdata = {}
+ assert not self.is_range()
+ assert not self.subdescriptors
+ pubkey = self.pubkeys[0].get_pubkey_bytes()
+ sig = sigdata.get(pubkey)
+ if sig is None and allow_dummy:
+ sig = DUMMY_DER_SIG
+ if sig is None:
+ raise MissingSolutionPiece(f"no sig for {pubkey.hex()}")
+ return ScriptSolutionInner(
+ witness_items=(sig,),
+ )
+
+ def get_satisfaction_progress(self, *, sigdata=None) -> Tuple[int, int]:
+ if sigdata is None: sigdata = {}
+ signatures = list(sigdata.values())
+ return len(signatures), 1
+
+ def get_simple_singlesig(self) -> Optional['Descriptor']:
+ return self
+
+
+class PKHDescriptor(Descriptor):
+ """
+ A descriptor for ``pkh()`` descriptors
+ """
+ def __init__(
+ self,
+ pubkey: 'PubkeyProvider'
+ ) -> None:
+ """
+ :param pubkey: The :class:`PubkeyProvider` for this descriptor
+ """
+ super().__init__([pubkey], [], "pkh")
+
+ def expand(self, *, pos: Optional[int] = None) -> "ExpandedScripts":
+ pubkey = self.pubkeys[0].get_pubkey_bytes(pos=pos)
+ pkh = hash_160(pubkey)
+ script = bitcoin.pubkeyhash_to_p2pkh_script(pkh)
+ return ExpandedScripts(output_script=script)
+
+ def _satisfy_inner(self, *, sigdata=None, allow_dummy=False) -> ScriptSolutionInner:
+ if sigdata is None: sigdata = {}
+ assert not self.is_range()
+ assert not self.subdescriptors
+ pubkey = self.pubkeys[0].get_pubkey_bytes()
+ sig = sigdata.get(pubkey)
+ if sig is None and allow_dummy:
+ sig = DUMMY_DER_SIG
+ if sig is None:
+ raise MissingSolutionPiece(f"no sig for {pubkey.hex()}")
+ return ScriptSolutionInner(
+ witness_items=(sig, pubkey),
+ )
+
+ def get_satisfaction_progress(self, *, sigdata=None) -> Tuple[int, int]:
+ if sigdata is None: sigdata = {}
+ signatures = list(sigdata.values())
+ return len(signatures), 1
+
+ def get_simple_singlesig(self) -> Optional['Descriptor']:
+ return self
+
+
+class WPKHDescriptor(Descriptor):
+ """
+ A descriptor for ``wpkh()`` descriptors
+ """
+ def __init__(
+ self,
+ pubkey: 'PubkeyProvider'
+ ) -> None:
+ """
+ :param pubkey: The :class:`PubkeyProvider` for this descriptor
+ """
+ super().__init__([pubkey], [], "wpkh")
+
+ def expand(self, *, pos: Optional[int] = None) -> "ExpandedScripts":
+ pkh = hash_160(self.pubkeys[0].get_pubkey_bytes(pos=pos))
+ output_script = construct_script([0, pkh])
+ scriptcode = bitcoin.pubkeyhash_to_p2pkh_script(pkh)
+ return ExpandedScripts(
+ output_script=output_script,
+ scriptcode_for_sighash=scriptcode,
+ )
+
+ def _satisfy_inner(self, *, sigdata=None, allow_dummy=False) -> ScriptSolutionInner:
+ if sigdata is None: sigdata = {}
+ assert not self.is_range()
+ assert not self.subdescriptors
+ pubkey = self.pubkeys[0].get_pubkey_bytes()
+ sig = sigdata.get(pubkey)
+ if sig is None and allow_dummy:
+ sig = DUMMY_DER_SIG
+ if sig is None:
+ raise MissingSolutionPiece(f"no sig for {pubkey.hex()}")
+ return ScriptSolutionInner(
+ witness_items=(sig, pubkey),
+ )
+
+ def get_satisfaction_progress(self, *, sigdata=None) -> Tuple[int, int]:
+ if sigdata is None: sigdata = {}
+ signatures = list(sigdata.values())
+ return len(signatures), 1
+
+ def is_segwit(self) -> bool:
+ return True
+
+ def get_simple_singlesig(self) -> Optional['Descriptor']:
+ return self
+
+
+class MultisigDescriptor(Descriptor):
+ """
+ A descriptor for ``multi()`` and ``sortedmulti()`` descriptors
+ """
+ def __init__(
+ self,
+ pubkeys: List['PubkeyProvider'],
+ thresh: int,
+ is_sorted: bool
+ ) -> None:
+ r"""
+ :param pubkeys: The :class:`PubkeyProvider`\ s for this descriptor
+ :param thresh: The number of keys required to sign this multisig
+ :param is_sorted: Whether this is a ``sortedmulti()`` descriptor
+ """
+ super().__init__(pubkeys, [], "sortedmulti" if is_sorted else "multi")
+ if not (1 <= thresh <= len(pubkeys) <= 15):
+ raise ValueError(f'{thresh=}, {len(pubkeys)=}')
+ self.thresh = thresh
+ self.is_sorted = is_sorted
+ if self.is_sorted:
+ if not self.is_range():
+ # sort xpubs using the order of pubkeys
+ der_pks = [p.get_pubkey_bytes() for p in self.pubkeys]
+ self.pubkeys = [x[1] for x in sorted(zip(der_pks, self.pubkeys))]
+ else:
+ # not possible to sort according to final order in expanded scripts,
+ # but for easier visual comparison, we do a lexicographical sort
+ self.pubkeys.sort()
+
+ def to_string_no_checksum(self) -> str:
+ return "{}({},{})".format(self.name, self.thresh, ",".join([p.to_string() for p in self.pubkeys]))
+
+ def expand(self, *, pos: Optional[int] = None) -> "ExpandedScripts":
+ der_pks = [p.get_pubkey_bytes(pos=pos) for p in self.pubkeys]
+ if self.is_sorted:
+ der_pks.sort()
+ script = construct_script([self.thresh, *der_pks, len(der_pks), opcodes.OP_CHECKMULTISIG])
+ return ExpandedScripts(output_script=script)
+
+ def _satisfy_inner(self, *, sigdata=None, allow_dummy=False) -> ScriptSolutionInner:
+ if sigdata is None: sigdata = {}
+ assert not self.is_range()
+ assert not self.subdescriptors
+ der_pks = [p.get_pubkey_bytes() for p in self.pubkeys]
+ if self.is_sorted:
+ der_pks.sort()
+ signatures = []
+ for pubkey in der_pks:
+ if sig := sigdata.get(pubkey):
+ signatures.append(sig)
+ if len(signatures) >= self.thresh:
+ break
+ if allow_dummy:
+ dummy_sig = DUMMY_DER_SIG
+ signatures += (self.thresh - len(signatures)) * [dummy_sig]
+ if len(signatures) < self.thresh:
+ raise MissingSolutionPiece(f"not enough sigs")
+ assert len(signatures) == self.thresh, f"thresh={self.thresh}, but got {len(signatures)} sigs"
+ return ScriptSolutionInner(
+ witness_items=(0, *signatures),
+ )
+
+ def get_satisfaction_progress(self, *, sigdata=None) -> Tuple[int, int]:
+ if sigdata is None: sigdata = {}
+ signatures = list(sigdata.values())
+ return len(signatures), self.thresh
+
+ def get_simple_multisig(self) -> Optional['MultisigDescriptor']:
+ return self
+
+
+class SHDescriptor(Descriptor):
+ """
+ A descriptor for ``sh()`` descriptors
+ """
+ def __init__(
+ self,
+ subdescriptor: 'Descriptor'
+ ) -> None:
+ """
+ :param subdescriptor: The :class:`Descriptor` that is a sub-descriptor for this descriptor
+ """
+ super().__init__([], [subdescriptor], "sh")
+
+ def expand(self, *, pos: Optional[int] = None) -> "ExpandedScripts":
+ assert len(self.subdescriptors) == 1
+ sub_scripts = self.subdescriptors[0].expand(pos=pos)
+ redeem_script = sub_scripts.output_script
+ witness_script = sub_scripts.witness_script
+ script = construct_script([opcodes.OP_HASH160, hash_160(redeem_script), opcodes.OP_EQUAL])
+ return ExpandedScripts(
+ output_script=script,
+ redeem_script=redeem_script,
+ witness_script=witness_script,
+ scriptcode_for_sighash=sub_scripts.scriptcode_for_sighash,
+ )
+
+ def _satisfy_inner(self, *, sigdata=None, allow_dummy=False) -> ScriptSolutionInner:
+ raise Exception("does not make sense for sh()")
+
+ def satisfy(self, *, sigdata=None, allow_dummy=False) -> ScriptSolutionTop:
+ assert not self.is_range()
+ assert len(self.subdescriptors) == 1
+ subdesc = self.subdescriptors[0]
+ redeem_script = self.expand().redeem_script
+ witness = None
+ if isinstance(subdesc, (WSHDescriptor, WPKHDescriptor)): # witness_v0 nested in p2sh
+ witness = subdesc.satisfy(sigdata=sigdata, allow_dummy=allow_dummy).witness
+ script_sig = construct_script([redeem_script])
+ else: # legacy p2sh
+ subsol = subdesc._satisfy_inner(sigdata=sigdata, allow_dummy=allow_dummy)
+ script_sig = construct_script([*subsol.witness_items, redeem_script])
+ return ScriptSolutionTop(
+ witness=witness,
+ script_sig=script_sig,
+ )
+
+
+class WSHDescriptor(Descriptor):
+ """
+ A descriptor for ``wsh()`` descriptors
+ """
+ def __init__(
+ self,
+ subdescriptor: 'Descriptor'
+ ) -> None:
+ """
+ :param subdescriptor: The :class:`Descriptor` that is a sub-descriptor for this descriptor
+ """
+ super().__init__([], [subdescriptor], "wsh")
+
+ def expand(self, *, pos: Optional[int] = None) -> "ExpandedScripts":
+ assert len(self.subdescriptors) == 1
+ sub_scripts = self.subdescriptors[0].expand(pos=pos)
+ witness_script = sub_scripts.output_script
+ output_script = construct_script([0, sha256(witness_script)])
+ return ExpandedScripts(
+ output_script=output_script,
+ witness_script=witness_script,
+ )
+
+ def _satisfy_inner(self, *, sigdata=None, allow_dummy=False) -> ScriptSolutionInner:
+ raise Exception("does not make sense for wsh()")
+
+ def satisfy(self, *, sigdata=None, allow_dummy=False) -> ScriptSolutionTop:
+ assert not self.is_range()
+ assert len(self.subdescriptors) == 1
+ subsol = self.subdescriptors[0]._satisfy_inner(sigdata=sigdata, allow_dummy=allow_dummy)
+ witness_script = self.expand().witness_script
+ witness = construct_witness([*subsol.witness_items, witness_script])
+ return ScriptSolutionTop(
+ witness=witness,
+ )
+
+ def is_segwit(self) -> bool:
+ return True
+
+
+class TRDescriptor(Descriptor):
+ """
+ A descriptor for ``tr()`` descriptors
+ """
+ def __init__(
+ self,
+ internal_key: 'PubkeyProvider',
+ desc_tree: List[Union['Descriptor', List]] = None,
+ ) -> None:
+ r"""
+ :param internal_key: The :class:`PubkeyProvider` that is the internal key for this descriptor
+ :param desc_tree: Taproot script binary tree, as a nested list of Descriptors
+ """
+ if desc_tree is None:
+ desc_tree = []
+ self.desc_tree = desc_tree
+ desc_list = []
+ if desc_tree:
+ if self.get_max_tree_depth() > MAX_TAPROOT_DEPTH:
+ raise ValueError(f"tr() supports at most {MAX_TAPROOT_DEPTH} nesting levels")
+ def flatten(tree_node):
+ if isinstance(tree_node, Descriptor):
+ return [tree_node]
+ assert len(tree_node) == 2, len(tree_node)
+ return flatten(tree_node[0]) + flatten(tree_node[1])
+ desc_list = flatten(desc_tree)
+ super().__init__(
+ pubkeys=[internal_key],
+ subdescriptors=desc_list, # FIXME we could do without the flattened list (dupl)
+ name="tr",
+ )
+
+ def to_string_no_checksum(self) -> str:
+ ret = f"{self.name}({self.pubkeys[0].to_string()}"
+ if self.desc_tree:
+ ret += ","
+ def tree_to_str(tree_node):
+ if isinstance(tree_node, Descriptor):
+ return tree_node.to_string_no_checksum()
+ assert len(tree_node) == 2, len(tree_node)
+ return "{" + tree_to_str(tree_node[0]) + "," + tree_to_str(tree_node[1]) + "}"
+ ret += tree_to_str(self.desc_tree)
+ ret += ")"
+ return ret
+
+ def is_segwit(self) -> bool:
+ return True
+
+ def is_taproot(self) -> bool:
+ return True
+
+ # TODO add more test vectors from BIP-0386
+ def expand(self, *, pos: Optional[int] = None) -> "ExpandedScripts":
+ internal_pubkey = self.pubkeys[0].get_pubkey_bytes(pos=pos)
+ script_tree = None
+ if self.desc_tree:
+ def transform(tree_node):
+ if isinstance(tree_node, Descriptor):
+ leaf_version = 0xc0
+ leaf_script = tree_node.expand(pos=pos).scriptcode_for_sighash # FIXME maybe rename scriptcode_for_sighash
+ return (leaf_version, leaf_script)
+ assert len(tree_node) == 2, len(tree_node)
+ return [transform(tree_node[0]), transform(tree_node[1])]
+ script_tree = transform(self.desc_tree)
+ output_script = taproot_output_script(internal_pubkey, script_tree=script_tree)
+ return ExpandedScripts(
+ output_script=output_script,
+ )
+
+ def get_max_tree_depth(self) -> Optional[int]:
+ if not self.desc_tree:
+ return None
+ def depth(tree_node) -> int:
+ if isinstance(tree_node, Descriptor):
+ return 0
+ assert len(tree_node) == 2, len(tree_node)
+ return 1 + max(depth(tree_node[0]), depth(tree_node[1]))
+ return depth(self.desc_tree)
+
+
+def _get_func_expr(s: str) -> Tuple[str, str]:
+ """
+ Get the function name and then the expression inside
+
+ :param s: The string that begins with a function name
+ :return: The function name as the first element of the tuple, and the expression contained within the function as the second element
+ :raises: ValueError: if a matching pair of parentheses cannot be found
+ """
+ try:
+ start = s.index("(")
+ end = s.rindex(")")
+ return s[0:start], s[start + 1:end]
+ except ValueError:
+ raise ValueError("A matching pair of parentheses cannot be found")
+
+
+def _get_const(s: str, const: str) -> str:
+ """
+ Get the first character of the string, make sure it is the expected character,
+ and return the rest of the string
+
+ :param s: The string that begins with a constant character
+ :param const: The constant character
+ :return: The remainder of the string without the constant character
+ :raises: ValueError: if the first character is not the constant character
+ """
+ if s[0] != const:
+ raise ValueError(f"Expected '{const}' but got '{s[0]}'")
+ return s[1:]
+
+
+def _get_expr(s: str) -> Tuple[str, str]:
+ """
+ Extract the expression that ``s`` begins with.
+
+ This will return the initial part of ``s``, up to the first comma or closing brace,
+ skipping ones that are surrounded by braces.
+
+ :param s: The string to extract the expression from
+ :return: A pair with the first item being the extracted expression and the second the rest of the string
+ """
+ level: int = 0
+ for i, c in enumerate(s):
+ if c in ["(", "{"]:
+ level += 1
+ elif level > 0 and c in [")", "}"]:
+ level -= 1
+ elif level == 0 and c in [")", "}", ","]:
+ break
+ else:
+ return s, ""
+ return s[0:i], s[i:]
+
+def parse_pubkey(expr: str, *, ctx: '_ParseDescriptorContext') -> Tuple['PubkeyProvider', str]:
+ """
+ Parses an individual pubkey expression from a string that may contain more than one pubkey expression.
+
+ :param expr: The expression to parse a pubkey expression from
+ :return: The :class:`PubkeyProvider` that is parsed as the first item of a tuple, and the remainder of the expression as the second item.
+ """
+ end = len(expr)
+ comma_idx = expr.find(",")
+ next_expr = ""
+ if comma_idx != -1:
+ end = comma_idx
+ next_expr = expr[end + 1:]
+ pubkey_provider = PubkeyProvider.parse(expr[:end])
+ permit_uncompressed = ctx in (_ParseDescriptorContext.TOP, _ParseDescriptorContext.P2SH)
+ if not permit_uncompressed and pubkey_provider.has_uncompressed_pubkey():
+ raise ValueError("uncompressed pubkeys are not allowed")
+ return pubkey_provider, next_expr
+
+
+class _ParseDescriptorContext(Enum):
+ """
+ :meta private:
+
+ Enum representing the level that we are in when parsing a descriptor.
+ Some expressions aren't allowed at certain levels, this helps us track those.
+ """
+
+ TOP = enum.auto() # The top level, not within any descriptor
+ P2SH = enum.auto() # Within an sh() descriptor
+ P2WPKH = enum.auto() # Within wpkh() descriptor
+ P2WSH = enum.auto() # Within a wsh() descriptor
+ P2TR = enum.auto() # Within a tr() descriptor
+
+
+def _parse_descriptor(desc: str, *, ctx: '_ParseDescriptorContext') -> 'Descriptor':
+ """
+ :meta private:
+
+ Parse a descriptor given the context level we are in.
+ Used recursively to parse subdescriptors
+
+ :param desc: The descriptor string to parse
+ :param ctx: The :class:`_ParseDescriptorContext` indicating the level we are in
+ :return: The parsed descriptor
+ :raises: ValueError: if the descriptor is malformed
+ """
+ func, expr = _get_func_expr(desc)
+ if func == "pk":
+ pubkey, expr = parse_pubkey(expr, ctx=ctx)
+ if expr:
+ raise ValueError("more than one pubkey in pk descriptor")
+ return PKDescriptor(pubkey)
+ if func == "pkh":
+ if not (ctx == _ParseDescriptorContext.TOP or ctx == _ParseDescriptorContext.P2SH or ctx == _ParseDescriptorContext.P2WSH):
+ raise ValueError("Can only have pkh at top level, in sh(), or in wsh()")
+ pubkey, expr = parse_pubkey(expr, ctx=ctx)
+ if expr:
+ raise ValueError("More than one pubkey in pkh descriptor")
+ return PKHDescriptor(pubkey)
+ if func == "sortedmulti" or func == "multi":
+ if not (ctx == _ParseDescriptorContext.TOP or ctx == _ParseDescriptorContext.P2SH or ctx == _ParseDescriptorContext.P2WSH):
+ raise ValueError("Can only have multi/sortedmulti at top level, in sh(), or in wsh()")
+ is_sorted = func == "sortedmulti"
+ comma_idx = expr.index(",")
+ thresh = int(expr[:comma_idx])
+ expr = expr[comma_idx + 1:]
+ pubkeys = []
+ while expr:
+ pubkey, expr = parse_pubkey(expr, ctx=ctx)
+ pubkeys.append(pubkey)
+ if len(pubkeys) == 0 or len(pubkeys) > 15:
+ raise ValueError("Cannot have {} keys in a multisig; must have between 1 and 15 keys, inclusive".format(len(pubkeys)))
+ elif thresh < 1:
+ raise ValueError("Multisig threshold cannot be {}, must be at least 1".format(thresh))
+ elif thresh > len(pubkeys):
+ raise ValueError("Multisig threshold cannot be larger than the number of keys; threshold is {} but only {} keys specified".format(thresh, len(pubkeys)))
+ if ctx == _ParseDescriptorContext.TOP and len(pubkeys) > 3:
+ raise ValueError("Cannot have {} pubkeys in bare multisig: only at most 3 pubkeys")
+ return MultisigDescriptor(pubkeys, thresh, is_sorted)
+ if func == "wpkh":
+ if not (ctx == _ParseDescriptorContext.TOP or ctx == _ParseDescriptorContext.P2SH):
+ raise ValueError("Can only have wpkh() at top level or inside sh()")
+ pubkey, expr = parse_pubkey(expr, ctx=_ParseDescriptorContext.P2WPKH)
+ if expr:
+ raise ValueError("More than one pubkey in pkh descriptor")
+ return WPKHDescriptor(pubkey)
+ if func == "sh":
+ if ctx != _ParseDescriptorContext.TOP:
+ raise ValueError("Can only have sh() at top level")
+ subdesc = _parse_descriptor(expr, ctx=_ParseDescriptorContext.P2SH)
+ return SHDescriptor(subdesc)
+ if func == "wsh":
+ if not (ctx == _ParseDescriptorContext.TOP or ctx == _ParseDescriptorContext.P2SH):
+ raise ValueError("Can only have wsh() at top level or inside sh()")
+ subdesc = _parse_descriptor(expr, ctx=_ParseDescriptorContext.P2WSH)
+ return WSHDescriptor(subdesc)
+ if func == "tr":
+ if ctx != _ParseDescriptorContext.TOP:
+ raise ValueError("Can only have tr at top level")
+ internal_key, expr = parse_pubkey(expr, ctx=ctx)
+ desc_tree = []
+ if expr:
+ def parse_tree(tree_str):
+ if len(tree_str) == 0:
+ raise ValueError("Invalid Taproot tree expression")
+ if tree_str[0] != "{": # leaf
+ sarg, remaining = _get_expr(tree_str)
+ return _parse_descriptor(sarg, ctx=_ParseDescriptorContext.P2TR), remaining
+ if len(tree_str) < len("{x,y}") or tree_str[-1] != "}":
+ raise ValueError("Invalid Taproot tree expression")
+ left, remaining = parse_tree(tree_str[1:])
+ if remaining[0] != ",": raise ValueError
+ right, remaining = parse_tree(remaining[1:])
+ if remaining[0] != "}": raise ValueError
+ return [left, right], remaining[1:]
+ desc_tree, _remaining = parse_tree(expr)
+ if len(_remaining) != 0: raise ValueError
+ return TRDescriptor(internal_key, desc_tree)
+ if ctx == _ParseDescriptorContext.P2SH:
+ raise ValueError("A function is needed within P2SH")
+ elif ctx == _ParseDescriptorContext.P2WSH:
+ raise ValueError("A function is needed within P2WSH")
+ raise ValueError("{} is not a valid descriptor function".format(func))
+
+
+def parse_descriptor(desc: str) -> 'Descriptor':
+ """
+ Parse a descriptor string into a :class:`Descriptor`.
+ Validates the checksum if one is provided in the string
+
+ :param desc: The descriptor string
+ :return: The parsed :class:`Descriptor`
+ :raises: ValueError: if the descriptor string is malformed
+ """
+ i = desc.find("#")
+ if i != -1:
+ checksum = desc[i + 1:]
+ desc = desc[:i]
+ computed = DescriptorChecksum(desc)
+ if computed != checksum:
+ raise ValueError("The checksum does not match; Got {}, expected {}".format(checksum, computed))
+ return _parse_descriptor(desc, ctx=_ParseDescriptorContext.TOP)
+
+
+#####
+
+
+class NotLegacySinglesigScriptType(Exception): pass
+
+
+def get_singlesig_descriptor_from_legacy_leaf(*, pubkey: str, script_type: str) -> Optional[Descriptor]:
+ pubkey = PubkeyProvider.parse(pubkey)
+ if script_type == 'p2pk':
+ return PKDescriptor(pubkey=pubkey)
+ elif script_type == 'p2pkh':
+ return PKHDescriptor(pubkey=pubkey)
+ elif script_type == 'p2wpkh':
+ return WPKHDescriptor(pubkey=pubkey)
+ elif script_type == 'p2wpkh-p2sh':
+ wpkh = WPKHDescriptor(pubkey=pubkey)
+ return SHDescriptor(subdescriptor=wpkh)
+ else:
+ raise NotLegacySinglesigScriptType(f"unexpected {script_type=}")
+
+
+def create_dummy_descriptor_from_address(addr: Optional[str]) -> 'Descriptor':
+ # It's not possible to tell the script type in general just from an address.
+ # - "1" addresses are of course p2pkh
+ # - "3" addresses are p2sh but we don't know the redeem script...
+ # - "bc1" addresses (if they are 42-long) are p2wpkh
+ # - "bc1" addresses that are 62-long are p2wsh but we don't know the script...
+ # If we don't know the script, we _guess_ it is pubkeyhash.
+ # As this method is used e.g. for tx size estimation,
+ # the estimation will not be precise.
+ def guess_script_type(addr: Optional[str]) -> str:
+ if addr is None:
+ return 'p2wpkh' # the default guess
+ witver, witprog = segwit_addr.decode_segwit_address(constants.net.SEGWIT_HRP, addr)
+ if witprog is not None:
+ return 'p2wpkh'
+ addrtype, hash_160_ = bitcoin.b58_address_to_hash160(addr)
+ if addrtype == constants.net.ADDRTYPE_P2PKH:
+ return 'p2pkh'
+ elif addrtype == constants.net.ADDRTYPE_P2SH:
+ return 'p2wpkh-p2sh'
+ raise Exception(f'unrecognized address: {repr(addr)}')
+
+ script_type = guess_script_type(addr)
+ # guess pubkey-len to be 33-bytes:
+ pubkey = ecc.GENERATOR.get_public_key_bytes(compressed=True).hex()
+ desc = get_singlesig_descriptor_from_legacy_leaf(pubkey=pubkey, script_type=script_type)
+ return desc
diff --git a/electrum/dns_hacks.py b/electrum/dns_hacks.py
new file mode 100644
index 000000000000..08708be8d07d
--- /dev/null
+++ b/electrum/dns_hacks.py
@@ -0,0 +1,106 @@
+# 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
+
+import sys
+import socket
+import concurrent
+from concurrent import futures
+import ipaddress
+import asyncio
+
+import dns
+import dns.asyncresolver
+
+from .logging import get_logger
+from .util import get_asyncio_loop
+from . import util
+
+_logger = get_logger(__name__)
+
+
+def configure_dns_resolver() -> None:
+ # Store this somewhere so we can un-monkey-patch:
+ if not hasattr(socket, "_getaddrinfo"):
+ socket._getaddrinfo = socket.getaddrinfo
+ if sys.platform == 'win32':
+ # On Windows, socket.getaddrinfo takes a mutex, and might hold it for up to 10 seconds
+ # when dns-resolving. To speed it up drastically, we resolve dns ourselves, outside that lock.
+ # See https://github.com/spesmilo/electrum/issues/4421
+ try:
+ _prepare_windows_dns_hack()
+ except Exception as e:
+ _logger.exception('failed to apply windows dns hack.')
+ else:
+ socket.getaddrinfo = _fast_getaddrinfo
+
+
+def _prepare_windows_dns_hack():
+ # enable dns cache
+ resolver = dns.asyncresolver.get_default_resolver()
+ if resolver.cache is None:
+ resolver.cache = dns.resolver.Cache()
+ # ensure overall timeout for requests is long enough
+ resolver.lifetime = max(resolver.lifetime or 1, 30.0)
+
+
+def _is_force_system_dns_for_host(host: str) -> bool:
+ return str(host) in ('localhost', 'localhost.',)
+
+
+def _fast_getaddrinfo(host, *args, **kwargs):
+ def needs_dns_resolving(host):
+ try:
+ ipaddress.ip_address(host)
+ return False # already valid IP
+ except ValueError:
+ pass # not an IP
+ if _is_force_system_dns_for_host(host):
+ return False
+ return True
+
+ def resolve_with_dnspython(host):
+ addrs = []
+ expected_errors = (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer,
+ concurrent.futures.CancelledError, concurrent.futures.TimeoutError)
+ loop = get_asyncio_loop()
+ assert util.get_running_loop() != loop, 'must not be called from asyncio thread'
+ ipv6_fut = asyncio.run_coroutine_threadsafe(
+ dns.asyncresolver.resolve(host, dns.rdatatype.AAAA),
+ loop,
+ )
+ ipv4_fut = asyncio.run_coroutine_threadsafe(
+ dns.asyncresolver.resolve(host, dns.rdatatype.A),
+ loop,
+ )
+ # try IPv6
+ try:
+ answers = ipv6_fut.result()
+ addrs += [str(answer) for answer in answers]
+ except expected_errors as e:
+ pass
+ except BaseException as e:
+ _logger.info(f'dnspython failed to resolve dns (AAAA) for {repr(host)} with error: {repr(e)}')
+ # try IPv4
+ try:
+ answers = ipv4_fut.result()
+ addrs += [str(answer) for answer in answers]
+ except expected_errors as e:
+ # dns failed for some reason, e.g. dns.resolver.NXDOMAIN this is normal.
+ # Simply report back failure; except if we already have some results.
+ if not addrs:
+ raise socket.gaierror(11001, 'getaddrinfo failed') from e
+ except BaseException as e:
+ # Possibly internal error in dnspython :( see #4483 and #5638
+ _logger.info(f'dnspython failed to resolve dns (A) for {repr(host)} with error: {repr(e)}')
+ if addrs:
+ return addrs
+ # Fall back to original socket.getaddrinfo to resolve dns.
+ return [host]
+
+ addrs = [host]
+ if needs_dns_resolving(host):
+ addrs = resolve_with_dnspython(host)
+ list_of_list_of_socketinfos = [socket._getaddrinfo(addr, *args, **kwargs) for addr in addrs]
+ list_of_socketinfos = [item for lst in list_of_list_of_socketinfos for item in lst]
+ return list_of_socketinfos
diff --git a/electrum/dnssec.py b/electrum/dnssec.py
new file mode 100644
index 000000000000..9a9d7337e962
--- /dev/null
+++ b/electrum/dnssec.py
@@ -0,0 +1,158 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2015 Thomas Voegtlin
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+# Check DNSSEC trust chain.
+# Todo: verify expiration dates
+#
+# Based on
+# http://backreference.org/2010/11/17/dnssec-verification-with-dig/
+# https://github.com/rthalley/dnspython/blob/master/tests/test_dnssec.py
+
+import logging
+
+import dns
+import dns.name
+import dns.asyncquery
+import dns.dnssec
+import dns.message
+import dns.asyncresolver
+import dns.rdatatype
+import dns.rdtypes.ANY.NS
+import dns.rdtypes.ANY.CNAME
+import dns.rdtypes.ANY.DLV
+import dns.rdtypes.ANY.DNSKEY
+import dns.rdtypes.ANY.DS
+import dns.rdtypes.ANY.NSEC
+import dns.rdtypes.ANY.NSEC3
+import dns.rdtypes.ANY.NSEC3PARAM
+import dns.rdtypes.ANY.RRSIG
+import dns.rdtypes.ANY.SOA
+import dns.rdtypes.ANY.TXT
+import dns.rdtypes.IN.A
+import dns.rdtypes.IN.AAAA
+
+from .logging import get_logger
+from typing import Tuple
+
+
+_logger = get_logger(__name__)
+
+
+# hard-coded trust anchors (root KSKs)
+trust_anchors = [
+ # KSK-2017:
+ dns.rrset.from_text('.', 1 , 'IN', 'DNSKEY', '257 3 8 AwEAAaz/tAm8yTn4Mfeh5eyI96WSVexTBAvkMgJzkKTOiW1vkIbzxeF3+/4RgWOq7HrxRixHlFlExOLAJr5emLvN7SWXgnLh4+B5xQlNVz8Og8kvArMtNROxVQuCaSnIDdD5LKyWbRd2n9WGe2R8PzgCmr3EgVLrjyBxWezF0jLHwVN8efS3rCj/EWgvIWgb9tarpVUDK/b58Da+sqqls3eNbuv7pr+eoZG+SrDK6nWeL3c6H5Apxz7LjVc1uTIdsIXxuOLYA4/ilBmSVIzuDWfdRUfhHdY6+cn8HFRm+2hM8AnXGXws9555KrUB5qihylGa8subX2Nn6UwNR1AkUTV74bU='),
+ # KSK-2010:
+ dns.rrset.from_text('.', 15202, 'IN', 'DNSKEY', '257 3 8 AwEAAagAIKlVZrpC6Ia7gEzahOR+9W29euxhJhVVLOyQbSEW0O8gcCjF FVQUTf6v58fLjwBd0YI0EzrAcQqBGCzh/RStIoO8g0NfnfL2MTJRkxoX bfDaUeVPQuYEhg37NZWAJQ9VnMVDxP/VHL496M/QZxkjf5/Efucp2gaD X6RS6CXpoY68LsvPVjR0ZSwzz1apAzvN9dlzEheX7ICJBBtuA6G3LQpz W5hOA2hzCTMjJPJ8LbqF6dsV6DoBQzgul0sGIcGOYl7OyQdXfZ57relS Qageu+ipAdTTJ25AsRTAoub8ONGcLmqrAmRLKBP1dfwhYB4N7knNnulq QxA+Uk1ihz0='),
+]
+
+
+async def _check_query(ns, sub, _type, keys) -> dns.rrset.RRset:
+ q = dns.message.make_query(sub, _type, want_dnssec=True)
+ response = await dns.asyncquery.tcp(q, ns, timeout=5)
+ assert response.rcode() == 0, 'No answer'
+ answer = response.answer
+ assert len(answer) != 0, ('No DNS record found', sub, _type)
+ assert len(answer) != 1, ('No DNSSEC record found', sub, _type)
+ if answer[0].rdtype == dns.rdatatype.RRSIG:
+ rrsig, rrset = answer
+ elif answer[1].rdtype == dns.rdatatype.RRSIG:
+ rrset, rrsig = answer
+ else:
+ raise Exception('No signature set in record')
+ if keys is None:
+ keys = {dns.name.from_text(sub):rrset}
+ dns.dnssec.validate(rrset, rrsig, keys)
+ return rrset
+
+
+async def _get_and_validate(ns, url, _type) -> dns.rrset.RRset:
+ # get trusted root key
+ root_rrset = None
+ for dnskey_rr in trust_anchors:
+ try:
+ # Check if there is a valid signature for the root dnskey
+ root_rrset = await _check_query(ns, '', dns.rdatatype.DNSKEY, {dns.name.root: dnskey_rr})
+ break
+ except dns.dnssec.ValidationFailure:
+ # It's OK as long as one key validates
+ continue
+ if not root_rrset:
+ raise dns.dnssec.ValidationFailure('None of the trust anchors found in DNS')
+ keys = {dns.name.root: root_rrset}
+ # top-down verification
+ parts = url.split('.')
+ for i in range(len(parts), 0, -1):
+ sub = '.'.join(parts[i-1:])
+ name = dns.name.from_text(sub)
+ # If server is authoritative, don't fetch DNSKEY
+ query = dns.message.make_query(sub, dns.rdatatype.NS)
+ response = await dns.asyncquery.udp(query, ns, 3)
+ assert response.rcode() == dns.rcode.NOERROR, "query error"
+ rrset = response.authority[0] if len(response.authority) > 0 else response.answer[0]
+ rr = rrset[0]
+ if rr.rdtype == dns.rdatatype.SOA:
+ continue
+ # get DNSKEY (self-signed)
+ rrset = await _check_query(ns, sub, dns.rdatatype.DNSKEY, None)
+ # get DS (signed by parent)
+ ds_rrset = await _check_query(ns, sub, dns.rdatatype.DS, keys)
+ # verify that a signed DS validates DNSKEY
+ for ds in ds_rrset:
+ for dnskey in rrset:
+ htype = 'SHA256' if ds.digest_type == 2 else 'SHA1'
+ good_ds = dns.dnssec.make_ds(name, dnskey, htype)
+ if ds == good_ds:
+ break
+ else:
+ continue
+ break
+ else:
+ raise Exception("DS does not match DNSKEY")
+ # set key for next iteration
+ keys = {name: rrset}
+ # get TXT record (signed by zone)
+ rrset = await _check_query(ns, url, _type, keys)
+ return rrset
+
+
+async def query(url: str, rtype: dns.rdatatype.RdataType) -> Tuple[dns.rrset.RRset, bool]:
+ """Try to do DNS resolution, including DNSSEC.
+ 'validated' shows whether the DNSSEC checks passed. DNS is completely INSECURE without DNSSEC,
+ so the caller must carefully consider whether the response can be used for anything if validated=False.
+ """
+ # FIXME this method is not using the network proxy. (although the proxy might not support UDP?)
+ # 8.8.8.8 is Google's public DNS server
+ nameservers = ['8.8.8.8']
+ ns = nameservers[0]
+ try:
+ out = await _get_and_validate(ns, url, rtype)
+ validated = True
+ except Exception as e:
+ log_level = logging.WARNING if isinstance(e, ImportError) else logging.INFO
+ _logger.log(log_level, f"DNSSEC error: {repr(e)}")
+ out = await dns.asyncresolver.resolve(url, rtype)
+ validated = False
+ return out, validated
diff --git a/electrum/electrum b/electrum/electrum
new file mode 120000
index 000000000000..74bf81ab6819
--- /dev/null
+++ b/electrum/electrum
@@ -0,0 +1 @@
+../run_electrum
\ No newline at end of file
diff --git a/electrum/exchange_rate.py b/electrum/exchange_rate.py
new file mode 100644
index 000000000000..6c758373547c
--- /dev/null
+++ b/electrum/exchange_rate.py
@@ -0,0 +1,842 @@
+import asyncio
+from datetime import datetime
+import inspect
+import sys
+import os
+import json
+import time
+import csv
+import decimal
+from decimal import Decimal
+from typing import Sequence, Optional, Mapping, Dict, Union, Tuple
+
+from aiorpcx.curio import timeout_after, ignore_after
+import aiohttp
+
+from . import util
+from .bitcoin import COIN
+from .i18n import _
+from .util import (
+ ThreadJob, make_dir, log_exceptions, OldTaskGroup, make_aiohttp_session, resource_path, EventListener,
+ event_listener, to_decimal, timestamp_to_datetime
+)
+from .util import NetworkRetryManager
+from .network import Network
+from .simple_config import SimpleConfig
+from .logging import Logger
+
+
+# See https://en.wikipedia.org/wiki/ISO_4217
+CCY_PRECISIONS = {'BHD': 3, 'BIF': 0, 'BYR': 0, 'CLF': 4, 'CLP': 0,
+ 'CVE': 0, 'DJF': 0, 'GNF': 0, 'IQD': 3, 'ISK': 0,
+ 'JOD': 3, 'JPY': 0, 'KMF': 0, 'KRW': 0, 'KWD': 3,
+ 'LYD': 3, 'MGA': 1, 'MRO': 1, 'OMR': 3, 'PYG': 0,
+ 'RWF': 0, 'TND': 3, 'UGX': 0, 'UYI': 0, 'VND': 0,
+ 'VUV': 0, 'XAF': 0, 'XAU': 4, 'XOF': 0, 'XPF': 0,
+ # Cryptocurrencies
+ 'BTC': 8, 'LTC': 6, 'XRP': 4, 'ETH': 8,
+ }
+
+SPOT_RATE_REFRESH_TARGET = 150 # approx. every 2.5 minutes, try to refresh spot price
+SPOT_RATE_CLOSE_TO_STALE = 450 # try harder to fetch an update if price is getting old
+SPOT_RATE_EXPIRY = 600 # spot price becomes stale after 10 minutes -> we no longer show/use it
+
+
+class ExchangeBase(Logger):
+
+ def __init__(self, on_quotes, on_history):
+ Logger.__init__(self)
+ self._history = {} # type: Dict[str, Dict[str, str | float]]
+ self._quotes = {} # type: Dict[str, Optional[Decimal]]
+ self._quotes_timestamp = 0 # type: Union[int, float]
+ self.on_quotes = on_quotes
+ self.on_history = on_history
+
+ async def get_raw(self, site, get_string):
+ # APIs must have https
+ url = ''.join(['https://', site, get_string])
+ network = Network.get_instance()
+ proxy = network.proxy if network else None
+ async with make_aiohttp_session(proxy) as session:
+ async with session.get(url) as response:
+ response.raise_for_status()
+ return await response.text()
+
+ async def get_json(self, site, get_string):
+ # APIs must have https
+ url = ''.join(['https://', site, get_string])
+ network = Network.get_instance()
+ proxy = network.proxy if network else None
+ async with make_aiohttp_session(proxy) as session:
+ async with session.get(url) as response:
+ response.raise_for_status()
+ # set content_type to None to disable checking MIME type
+ return await response.json(content_type=None)
+
+ async def get_csv(self, site, get_string):
+ raw = await self.get_raw(site, get_string)
+ reader = csv.DictReader(raw.split('\n'))
+ return list(reader)
+
+ def name(self):
+ return self.__class__.__name__
+
+ async def update_safe(self, ccy: str) -> None:
+ try:
+ self.logger.info(f"getting fx quotes for {ccy}")
+ self._quotes = await self.get_rates(ccy)
+ assert all(isinstance(rate, (Decimal, type(None))) for rate in self._quotes.values()), \
+ f"fx rate must be Decimal, got {self._quotes}"
+ except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as e:
+ self.logger.info(f"failed fx quotes: {repr(e)}")
+ self.on_quotes()
+ except Exception as e:
+ self.logger.exception(f"failed fx quotes: {repr(e)}")
+ self.on_quotes()
+ else:
+ self.logger.debug("received fx quotes")
+ self._quotes_timestamp = time.time()
+ self.on_quotes(received_new_data=True)
+
+ @staticmethod
+ def _read_historical_rates_from_file(
+ *, exchange_name: str, ccy: str, cache_dir: str,
+ ) -> Tuple[Optional[Dict[str, str]], Optional[float]]:
+ filename = os.path.join(cache_dir, f"{exchange_name}_{ccy}")
+ if not os.path.exists(filename):
+ return None, None
+ timestamp = os.stat(filename).st_mtime
+ try:
+ with open(filename, 'r', encoding='utf-8') as f:
+ h = json.loads(f.read())
+ except Exception:
+ return None, None
+ if not h: # e.g. empty dict
+ return None, None
+ # cast rates to str
+ h = {date_str: str(rate) for (date_str, rate) in h.items()}
+ return h, timestamp
+
+ def read_historical_rates(self, ccy: str, cache_dir: str) -> Optional[dict]:
+ h, timestamp = self._read_historical_rates_from_file(
+ exchange_name=self.name(),
+ ccy=ccy,
+ cache_dir=cache_dir,
+ )
+ if not h:
+ return None
+ assert timestamp is not None
+ h['timestamp'] = timestamp
+ self._history[ccy] = h
+ self.on_history()
+ return h
+
+ @staticmethod
+ def _write_historical_rates_to_file(
+ *, exchange_name: str, ccy: str, cache_dir: str, history: Dict[str, str],
+ ) -> None:
+ # sanity check types of history dict
+ assert 'timestamp' not in history
+ for key, rate in history.items():
+ assert isinstance(key, str), f"{exchange_name=}. {ccy=}. {key=!r}. {rate=!r}"
+ assert isinstance(rate, str), f"{exchange_name=}. {ccy=}. {key=!r}. {rate=!r}"
+ # write to file
+ filename = os.path.join(cache_dir, f"{exchange_name}_{ccy}")
+ with open(filename, 'w', encoding='utf-8') as f:
+ f.write(json.dumps(history, sort_keys=True))
+
+ @log_exceptions
+ async def get_historical_rates_safe(self, ccy: str, cache_dir: str) -> None:
+ try:
+ self.logger.info(f"requesting fx history for {ccy}")
+ h_new = await self.request_history(ccy)
+ self.logger.debug(f"received fx history for {ccy}")
+ except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as e:
+ self.logger.info(f"failed fx history: {repr(e)}")
+ return
+ except Exception as e:
+ self.logger.exception(f"failed fx history: {repr(e)}")
+ return
+ # cast rates to str
+ h_new = {date_str: str(rate) for (date_str, rate) in h_new.items()} # type: Dict[str, str]
+ # merge old history and new history. resolve duplicate dates using new data.
+ h_old, _timestamp = self._read_historical_rates_from_file(
+ exchange_name=self.name(), ccy=ccy, cache_dir=cache_dir,
+ )
+ h_old = h_old or {}
+ h = {**h_old, **h_new}
+ # write merged data to disk cache
+ self._write_historical_rates_to_file(
+ exchange_name=self.name(), ccy=ccy, cache_dir=cache_dir, history=h,
+ )
+ h['timestamp'] = time.time() # note: this is the only item in h that has a float value
+ self._history[ccy] = h
+ self.on_history()
+
+ def get_historical_rates(self, ccy: str, cache_dir: str) -> None:
+ if ccy not in self.history_ccys():
+ return
+ h = self._history.get(ccy)
+ if h is None:
+ h = self.read_historical_rates(ccy, cache_dir)
+ if h is None or h['timestamp'] < time.time() - 24*3600:
+ util.get_asyncio_loop().create_task(self.get_historical_rates_safe(ccy, cache_dir))
+
+ def history_ccys(self) -> Sequence[str]:
+ return []
+
+ def historical_rate(self, ccy: str, d_t: datetime) -> Decimal:
+ date_str = d_t.strftime('%Y-%m-%d')
+ rate = self._history.get(ccy, {}).get(date_str) or 'NaN'
+ try:
+ return Decimal(rate)
+ except Exception: # guard against garbage coming from exchange
+ #self.logger.debug(f"found corrupted historical_rate: {rate=!r}. for {ccy=} at {date_str}")
+ return Decimal('NaN')
+
+ async def request_history(self, ccy: str) -> Dict[str, Union[str, float]]:
+ raise NotImplementedError() # implemented by subclasses
+
+ async def get_rates(self, ccy: str) -> Mapping[str, Optional[Decimal]]:
+ raise NotImplementedError() # implemented by subclasses
+
+ async def get_currencies(self) -> Sequence[str]:
+ rates = await self.get_rates('')
+ return sorted([str(a) for (a, b) in rates.items() if b is not None and len(a)==3])
+
+ def get_cached_spot_quote(self, ccy: str) -> Decimal:
+ """Returns the cached exchange rate as a Decimal"""
+ if ccy == 'BTC':
+ return Decimal(1)
+ rate = self._quotes.get(ccy)
+ if not rate: # don't return 0 to prevent DivisionByZero exceptions
+ return Decimal('NaN')
+ if self._quotes_timestamp + SPOT_RATE_EXPIRY < time.time():
+ # Our rate is stale. Probably better to return no rate than an incorrect one.
+ return Decimal('NaN')
+ return Decimal(rate)
+
+
+class Yadio(ExchangeBase):
+
+ async def get_currencies(self):
+ dicts = await self.get_json('api.yadio.io', '/currencies')
+ return list(dicts.keys())
+
+ async def get_rates(self, ccy: str) -> Mapping[str, Optional[Decimal]]:
+ json = await self.get_json('api.yadio.io', '/rate/%s/BTC' % ccy)
+ return {ccy: to_decimal(json['rate'])}
+
+
+class BitcoinAverage(ExchangeBase):
+ # note: historical rates used to be freely available
+ # but this is no longer the case. see #5188
+
+ async def get_rates(self, ccy):
+ json = await self.get_json('apiv2.bitcoinaverage.com', '/indices/global/ticker/short')
+ return dict([(r.replace("BTC", ""), to_decimal(json[r]['last']))
+ for r in json if r != 'timestamp'])
+
+
+class Bitcointoyou(ExchangeBase):
+
+ async def get_rates(self, ccy):
+ json = await self.get_json('bitcointoyou.com', "/API/ticker.aspx")
+ return {'BRL': to_decimal(json['ticker']['last'])}
+
+
+class BitcoinVenezuela(ExchangeBase):
+
+ async def get_rates(self, ccy):
+ json = await self.get_json('api.bitcoinvenezuela.com', '/')
+ rates = [(r, to_decimal(json['BTC'][r])) for r in json['BTC']
+ if json['BTC'][r] is not None] # Giving NULL for LTC
+ return dict(rates)
+
+ def history_ccys(self):
+ return ['ARS', 'EUR', 'USD', 'VEF']
+
+ async def request_history(self, ccy):
+ json = await self.get_json('api.bitcoinvenezuela.com', "/historical/index.php?coin=BTC")
+ return json[ccy + '_BTC']
+
+
+class Bitbank(ExchangeBase):
+
+ async def get_rates(self, ccy):
+ json = await self.get_json('public.bitbank.cc', '/btc_jpy/ticker')
+ return {'JPY': to_decimal(json['data']['last'])}
+
+
+class BitFinex(ExchangeBase):
+
+ async def get_currencies(self):
+ json = await self.get_json(
+ 'api-pub.bitfinex.com',
+ f"/v2/conf/pub:list:pair:exchange")
+ pairs = [pair for pair in json[0]
+ if len(pair) == 6 and pair[:3] == "BTC"]
+ return [pair[3:] for pair in pairs]
+
+ def history_ccys(self):
+ return CURRENCIES[self.name()]
+
+ async def get_rates(self, ccy):
+ # ref https://docs.bitfinex.com/reference/rest-public-ticker
+ json = await self.get_json(
+ 'api-pub.bitfinex.com',
+ f"/v2/ticker/tBTC{ccy}")
+ return {ccy: to_decimal(json[6])}
+
+ async def request_history(self, ccy):
+ # ref https://docs.bitfinex.com/reference/rest-public-candles
+ history = await self.get_json(
+ 'api.bitfinex.com',
+ f"/v2/candles/trade:1D:tBTC{ccy}/hist?limit=10000")
+ return dict([(timestamp_to_datetime(h[0] // 1000, utc=True).strftime('%Y-%m-%d'), str(h[2]))
+ for h in history])
+
+
+class BitFlyer(ExchangeBase):
+
+ async def get_rates(self, ccy):
+ json = await self.get_json('bitflyer.jp', '/api/echo/price')
+ return {'JPY': to_decimal(json['mid'])}
+
+
+class BitPay(ExchangeBase):
+
+ async def get_rates(self, ccy):
+ json = await self.get_json('bitpay.com', '/api/rates')
+ return dict([(r['code'], to_decimal(r['rate'])) for r in json])
+
+
+class Bitso(ExchangeBase):
+
+ async def get_rates(self, ccy):
+ json = await self.get_json('api.bitso.com', '/v2/ticker')
+ return {'MXN': to_decimal(json['last'])}
+
+
+class BitStamp(ExchangeBase):
+
+ async def get_currencies(self):
+ # ref https://www.bitstamp.net/api/#tag/Tickers/operation/GetCurrencyPairTickers
+ json = await self.get_json(
+ 'www.bitstamp.net',
+ f"/api/v2/ticker/")
+ pairs = [ticker["pair"] for ticker in json]
+ pairs = [pair for pair in pairs
+ if len(pair) == 7 and pair[:4] == "BTC/"]
+ return [pair[4:] for pair in pairs]
+
+ async def get_rates(self, ccy):
+ # ref https://www.bitstamp.net/api/#tag/Tickers/operation/GetMarketTicker
+ if ccy in CURRENCIES[self.name()]:
+ json = await self.get_json('www.bitstamp.net', f'/api/v2/ticker/btc{ccy.lower()}/')
+ return {ccy: to_decimal(json['last'])}
+ return {}
+
+ def history_ccys(self):
+ return CURRENCIES[self.name()]
+
+ async def request_history(self, ccy):
+ # ref https://www.bitstamp.net/api/#tag/Market-info/operation/GetOHLCData
+ merged_history = {}
+ history_starts = 1313625600 # for BTCUSD pair (probably earliest)
+ items_per_request = 1000
+ step = 86400
+
+ async def populate_history(endtime: int):
+ history = await self.get_json(
+ 'www.bitstamp.net',
+ f"/api/v2/ohlc/btc{ccy.lower()}/?step={step}&limit={items_per_request}&end={endtime}")
+ history = dict([
+ (timestamp_to_datetime(int(h["timestamp"]), utc=True).strftime('%Y-%m-%d'), str(h["close"]))
+ for h in history["data"]["ohlc"]])
+ merged_history.update(history)
+
+ async with OldTaskGroup() as group:
+ endtime = int(time.time())
+ while True:
+ if endtime < history_starts:
+ break
+ await group.spawn(populate_history(endtime=endtime))
+ endtime = endtime - items_per_request * step
+ return merged_history
+
+
+class Bitvalor(ExchangeBase):
+
+ async def get_rates(self,ccy):
+ json = await self.get_json('api.bitvalor.com', '/v1/ticker.json')
+ return {'BRL': to_decimal(json['ticker_1h']['total']['last'])}
+
+
+class BlockchainInfo(ExchangeBase):
+
+ async def get_rates(self, ccy):
+ json = await self.get_json('blockchain.info', '/ticker')
+ return dict([(r, to_decimal(json[r]['15m'])) for r in json])
+
+
+class Bylls(ExchangeBase):
+
+ async def get_rates(self, ccy):
+ json = await self.get_json('bylls.com', '/api/price?from_currency=BTC&to_currency=CAD')
+ return {'CAD': to_decimal(json['public_price']['to_price'])}
+
+
+class Coinbase(ExchangeBase):
+
+ async def get_rates(self, ccy):
+ json = await self.get_json('api.coinbase.com',
+ '/v2/exchange-rates?currency=BTC')
+ return {ccy: to_decimal(rate) for (ccy, rate) in json["data"]["rates"].items()}
+
+
+class CoinCap(ExchangeBase):
+
+ async def get_rates(self, ccy):
+ json = await self.get_json('api.coincap.io', '/v2/rates/bitcoin/')
+ return {'USD': to_decimal(json['data']['rateUsd'])}
+
+ def history_ccys(self):
+ return ['USD']
+
+ async def request_history(self, ccy):
+ # Currently 2000 days is the maximum in 1 API call
+ # (and history starts on 2017-03-23)
+ history = await self.get_json('api.coincap.io',
+ '/v2/assets/bitcoin/history?interval=d1&limit=2000')
+ return dict([(timestamp_to_datetime(h['time']/1000, utc=True).strftime('%Y-%m-%d'), str(h['priceUsd']))
+ for h in history['data']])
+
+
+class CoinDesk(ExchangeBase):
+
+ async def get_currencies(self):
+ dicts = await self.get_json('api.coindesk.com',
+ '/v1/bpi/supported-currencies.json')
+ return [d['currency'] for d in dicts]
+
+ async def get_rates(self, ccy):
+ json = await self.get_json('api.coindesk.com',
+ '/v1/bpi/currentprice/%s.json' % ccy)
+ result = {ccy: to_decimal(json['bpi'][ccy]['rate_float'])}
+ return result
+
+ def history_starts(self):
+ return {'USD': '2012-11-30', 'EUR': '2013-09-01'}
+
+ def history_ccys(self):
+ return self.history_starts().keys()
+
+ async def request_history(self, ccy):
+ start = self.history_starts()[ccy]
+ end = datetime.today().strftime('%Y-%m-%d')
+ # Note ?currency and ?index don't work as documented. Sigh.
+ query = ('/v1/bpi/historical/close.json?start=%s&end=%s'
+ % (start, end))
+ json = await self.get_json('api.coindesk.com', query)
+ return json['bpi']
+
+
+class CoinGecko(ExchangeBase):
+
+ async def get_rates(self, ccy):
+ json = await self.get_json('api.coingecko.com', '/api/v3/exchange_rates')
+ return dict([(ccy.upper(), to_decimal(d['value']))
+ for ccy, d in json['rates'].items() if d.get('value') is not None])
+
+ def history_ccys(self):
+ # CoinGecko seems to have historical data for all ccys it supports
+ return CURRENCIES[self.name()]
+
+ async def request_history(self, ccy):
+ # ref https://docs.coingecko.com/v3.0.1/reference/coins-id-market-chart
+ num_days = 365
+ # Setting `num_days = "max"` started erroring (around 2024-04) with:
+ # > Your request exceeds the allowed time range. Public API users are limited to querying
+ # > historical data within the past 365 days. Upgrade to a paid plan to enjoy full historical data access
+ history = await self.get_json('api.coingecko.com',
+ f"/api/v3/coins/bitcoin/market_chart?vs_currency={ccy}&days={num_days}")
+
+ return dict([(timestamp_to_datetime(h[0]/1000, utc=True).strftime('%Y-%m-%d'), str(h[1]))
+ for h in history['prices']])
+
+
+class Bit2C(ExchangeBase):
+
+ async def get_rates(self, ccy):
+ json = await self.get_json('bit2c.co.il', '/Exchanges/BtcNis/Ticker.json')
+ return {'ILS': to_decimal(json['ll'])}
+
+ def history_ccys(self):
+ return CURRENCIES[self.name()]
+
+ async def request_history(self, ccy):
+ history = await self.get_json('bit2c.co.il',
+ '/Exchanges/BtcNis/KLines?resolution=1D&from=1357034400&to=%s' % int(time.time()))
+
+ return dict([(timestamp_to_datetime(h[0], utc=True).strftime('%Y-%m-%d'), str(h[6]))
+ for h in history])
+
+
+class CointraderMonitor(ExchangeBase):
+
+ async def get_rates(self, ccy):
+ json = await self.get_json('cointradermonitor.com', '/api/pbb/v1/ticker')
+ return {'BRL': to_decimal(json['last'])}
+
+
+class itBit(ExchangeBase):
+
+ async def get_rates(self, ccy):
+ ccys = ['USD', 'EUR', 'SGD']
+ json = await self.get_json('api.itbit.com', '/v1/markets/XBT%s/ticker' % ccy)
+ result = dict.fromkeys(ccys)
+ if ccy in ccys:
+ result[ccy] = to_decimal(json['lastPrice'])
+ return result
+
+
+class Kraken(ExchangeBase):
+
+ async def get_rates(self, ccy):
+ # ref https://docs.kraken.com/api/docs/rest-api/get-ticker-information
+ ccys = ['EUR', 'USD', 'CAD', 'GBP', 'JPY']
+ pairs = ['XBT%s' % c for c in ccys]
+ json = await self.get_json('api.kraken.com',
+ '/0/public/Ticker?pair=%s' % ','.join(pairs))
+ return dict((k[-3:], to_decimal(v['c'][0]))
+ for k, v in json['result'].items())
+
+ # async def request_history(self, ccy):
+ # # ref https://docs.kraken.com/api/docs/rest-api/get-ohlc-data
+ # pass # limited to last 720 steps (step can by 1 day / 7 days / 15 days)
+
+
+class MercadoBitcoin(ExchangeBase):
+
+ async def get_rates(self, ccy):
+ json = await self.get_json('api.bitvalor.com', '/v1/ticker.json')
+ return {'BRL': to_decimal(json['ticker_1h']['exchanges']['MBT']['last'])}
+
+
+class Winkdex(ExchangeBase):
+
+ async def get_rates(self, ccy):
+ json = await self.get_json('winkdex.com', '/api/v0/price')
+ return {'USD': to_decimal(json['price']) / 100}
+
+ def history_ccys(self):
+ return ['USD']
+
+ async def request_history(self, ccy):
+ json = await self.get_json('winkdex.com',
+ "/api/v0/series?start_time=1342915200")
+ history = json['series'][0]['results']
+ return dict([(h['timestamp'][:10], str(to_decimal(h['price']) / 100))
+ for h in history])
+
+
+class Zaif(ExchangeBase):
+ async def get_rates(self, ccy):
+ json = await self.get_json('api.zaif.jp', '/api/1/last_price/btc_jpy')
+ return {'JPY': to_decimal(json['last_price'])}
+
+
+class Bitragem(ExchangeBase):
+
+ async def get_rates(self,ccy):
+ json = await self.get_json('api.bitragem.com', '/v1/index?asset=BTC&market=BRL')
+ return {'BRL': to_decimal(json['response']['index'])}
+
+
+class Biscoint(ExchangeBase):
+
+ async def get_rates(self,ccy):
+ json = await self.get_json('api.biscoint.io', '/v1/ticker?base=BTC"e=BRL')
+ return {'BRL': to_decimal(json['data']['last'])}
+
+
+class Walltime(ExchangeBase):
+
+ async def get_rates(self, ccy):
+ json = await self.get_json('s3.amazonaws.com',
+ '/data-production-walltime-info/production/dynamic/walltime-info.json')
+ return {'BRL': to_decimal(json['BRL_XBT']['last_inexact'])}
+
+
+def dictinvert(d):
+ inv = {}
+ for k, vlist in d.items():
+ for v in vlist:
+ keys = inv.setdefault(v, [])
+ keys.append(k)
+ return inv
+
+def get_exchanges_and_currencies():
+ # load currencies.json from disk
+ path = resource_path('currencies.json')
+ try:
+ with open(path, 'r', encoding='utf-8') as f:
+ return json.loads(f.read())
+ except Exception:
+ pass
+ # or if not present, generate it now.
+ print("cannot find currencies.json. will regenerate it now.")
+ d = {}
+ is_exchange = lambda obj: (inspect.isclass(obj)
+ and issubclass(obj, ExchangeBase)
+ and obj != ExchangeBase)
+ exchanges = dict(inspect.getmembers(sys.modules[__name__], is_exchange))
+
+ async def get_currencies_safe(name, exchange):
+ try:
+ d[name] = await exchange.get_currencies()
+ print(name, "ok")
+ except Exception:
+ print(name, "error")
+
+ async def query_all_exchanges_for_their_ccys_over_network():
+ async with timeout_after(10):
+ async with OldTaskGroup() as group:
+ for name, klass in exchanges.items():
+ exchange = klass(None, None)
+ await group.spawn(get_currencies_safe(name, exchange))
+
+ loop = asyncio.new_event_loop()
+ try:
+ loop.run_until_complete(query_all_exchanges_for_their_ccys_over_network())
+ except Exception as e:
+ pass
+ finally:
+ loop.close()
+ with open(path, 'w', encoding='utf-8') as f:
+ f.write(json.dumps(d, indent=4, sort_keys=True))
+ return d
+
+
+CURRENCIES = get_exchanges_and_currencies()
+
+
+def get_exchanges_by_ccy(history=True):
+ if not history:
+ return dictinvert(CURRENCIES)
+ d = {}
+ exchanges = CURRENCIES.keys()
+ for name in exchanges:
+ klass = globals()[name]
+ exchange = klass(None, None)
+ d[name] = exchange.history_ccys()
+ return dictinvert(d)
+
+
+class FxThread(ThreadJob, EventListener, NetworkRetryManager[str]):
+
+ def __init__(self, *, config: SimpleConfig):
+ ThreadJob.__init__(self)
+ NetworkRetryManager.__init__(
+ self,
+ max_retry_delay_normal=SPOT_RATE_REFRESH_TARGET,
+ init_retry_delay_normal=SPOT_RATE_REFRESH_TARGET,
+ max_retry_delay_urgent=SPOT_RATE_REFRESH_TARGET,
+ init_retry_delay_urgent=1,
+ ) # note: we poll every 5 seconds for action, so we won't attempt connections more frequently than that.
+ self.config = config
+ self.register_callbacks()
+ self.ccy = self.get_currency()
+ self.history_used_spot = False
+ self.ccy_combo = None
+ self.hist_checkbox = None
+ self.cache_dir = os.path.join(config.path, 'cache') # type: str
+ self._trigger = asyncio.Event()
+ self._trigger.set()
+ self.set_exchange(self.config_exchange())
+ make_dir(self.cache_dir)
+
+ @event_listener
+ def on_event_proxy_set(self, *args):
+ self._clear_addr_retry_times()
+ self._trigger.set()
+
+ @staticmethod
+ def get_currencies(history: bool) -> Sequence[str]:
+ d = get_exchanges_by_ccy(history)
+ return sorted(d.keys())
+
+ @staticmethod
+ def get_exchanges_by_ccy(ccy: str, history: bool) -> Sequence[str]:
+ d = get_exchanges_by_ccy(history)
+ return d.get(ccy, [])
+
+ @staticmethod
+ def remove_thousands_separator(text: str) -> str:
+ return text.replace(util.THOUSANDS_SEP, "")
+
+ def ccy_amount_str(self, amount, *, add_thousands_sep: bool = False, ccy=None) -> str:
+ prec = CCY_PRECISIONS.get(self.ccy if ccy is None else ccy, 2)
+ fmt_str = "{:%s.%df}" % ("," if add_thousands_sep else "", max(0, prec))
+ try:
+ rounded_amount = round(amount, prec)
+ except decimal.InvalidOperation:
+ rounded_amount = amount
+ text = fmt_str.format(rounded_amount)
+ # replace "," -> THOUSANDS_SEP
+ # replace "." -> DECIMAL_POINT
+ dp_loc = text.find(".")
+ text = text.replace(",", util.THOUSANDS_SEP)
+ if dp_loc == -1:
+ return text
+ return text[:dp_loc] + util.DECIMAL_POINT + text[dp_loc+1:]
+
+ def ccy_precision(self, ccy=None) -> int:
+ return CCY_PRECISIONS.get(self.ccy if ccy is None else ccy, 2)
+
+ async def run(self):
+ while True:
+ # keep polling and see if we should refresh spot price or historical prices
+ manually_triggered = False
+ async with ignore_after(5):
+ await self._trigger.wait()
+ self._trigger.clear()
+ manually_triggered = True
+ if not self.is_enabled():
+ continue
+ if manually_triggered and self.has_history(): # maybe refresh historical prices
+ self.exchange.get_historical_rates(self.ccy, self.cache_dir)
+ now = time.time()
+ if not manually_triggered and self.exchange._quotes_timestamp + SPOT_RATE_REFRESH_TARGET > now:
+ continue # last quote still fresh
+ # If the last quote is relatively recent, we poll at fixed time intervals.
+ # Once it gets close to cache expiry, we change to an exponential backoff, to try to get
+ # a quote before it expires. Also, on Android, we might come back from a sleep after a long time,
+ # with the last quote close to expiry or already expired, in that case we go into exponential backoff.
+ is_urgent = self.exchange._quotes_timestamp + SPOT_RATE_CLOSE_TO_STALE < now
+ addr_name = "spot-urgent" if is_urgent else "spot" # this separates retry-counters
+ if self._can_retry_addr(addr_name, urgent=is_urgent):
+ self._trying_addr_now(addr_name)
+ # refresh spot price
+ await self.exchange.update_safe(self.ccy)
+
+ def is_enabled(self) -> bool:
+ return self.config.FX_USE_EXCHANGE_RATE
+
+ def set_enabled(self, b: bool) -> None:
+ self.config.FX_USE_EXCHANGE_RATE = b
+ self.trigger_update()
+
+ def can_have_history(self):
+ return self.is_enabled() and self.ccy in self.exchange.history_ccys()
+
+ def has_history(self) -> bool:
+ return self.can_have_history() and self.config.FX_HISTORY_RATES
+
+ def get_currency(self) -> str:
+ '''Use when dynamic fetching is needed'''
+ return self.config.FX_CURRENCY
+
+ def config_exchange(self):
+ return self.config.FX_EXCHANGE
+
+ def set_currency(self, ccy: str):
+ self.ccy = ccy
+ self.config.FX_CURRENCY = ccy
+ self.trigger_update()
+ self.on_quotes()
+
+ def trigger_update(self):
+ self._clear_addr_retry_times()
+ loop = util.get_asyncio_loop()
+ loop.call_soon_threadsafe(self._trigger.set)
+
+ def set_exchange(self, name):
+ class_ = globals().get(name) or globals().get(self.config.cv.FX_EXCHANGE.get_default_value())
+ self.logger.info(f"using exchange {name}")
+ if self.config_exchange() != name:
+ self.config.FX_EXCHANGE = name
+ assert issubclass(class_, ExchangeBase), f"unexpected type {class_} for {name}"
+ self.exchange = class_(self.on_quotes, self.on_history) # type: ExchangeBase
+ # A new exchange means new fx quotes, initially empty. Force
+ # a quote refresh
+ self.trigger_update()
+ self.exchange.read_historical_rates(self.ccy, self.cache_dir)
+
+ def on_quotes(self, *, received_new_data: bool = False):
+ if received_new_data:
+ self._clear_addr_retry_times()
+ util.trigger_callback('on_quotes')
+
+ def on_history(self):
+ util.trigger_callback('on_history')
+
+ def exchange_rate(self) -> Decimal:
+ """Returns the exchange rate as a Decimal"""
+ if not self.is_enabled():
+ return Decimal('NaN')
+ return self.exchange.get_cached_spot_quote(self.ccy)
+
+ def format_amount(self, btc_balance, *, timestamp: int = None) -> str:
+ if timestamp is None:
+ rate = self.exchange_rate()
+ else:
+ rate = self.timestamp_rate(timestamp)
+ return '' if rate.is_nan() else "%s" % self.value_str(btc_balance, rate)
+
+ def format_amount_and_units(self, btc_balance, *, timestamp: int = None) -> str:
+ if timestamp is None:
+ rate = self.exchange_rate()
+ else:
+ rate = self.timestamp_rate(timestamp)
+ return '' if rate.is_nan() else "%s %s" % (self.value_str(btc_balance, rate), self.ccy)
+
+ def get_fiat_status_text(self, btc_balance, base_unit, decimal_point):
+ rate = self.exchange_rate()
+ if rate.is_nan():
+ return _(" (No FX rate available)")
+ amount = 1000 if decimal_point == 0 else 1
+ value = self.value_str(amount * COIN / (10**(8 - decimal_point)), rate)
+ return " %d %s~%s %s" % (amount, base_unit, value, self.ccy)
+
+ def fiat_value(self, satoshis, rate) -> Decimal:
+ return Decimal('NaN') if satoshis is None else Decimal(satoshis) / COIN * Decimal(rate)
+
+ def value_str(self, satoshis, rate, *, add_thousands_sep: bool = None) -> str:
+ fiat_val = self.fiat_value(satoshis, rate)
+ return self.format_fiat(fiat_val, add_thousands_sep=add_thousands_sep)
+
+ def format_fiat(self, value: Decimal, *, add_thousands_sep: bool = None) -> str:
+ if value.is_nan():
+ return _("No data")
+ if add_thousands_sep is None:
+ add_thousands_sep = True
+ return self.ccy_amount_str(value, add_thousands_sep=add_thousands_sep)
+
+ def history_rate(self, d_t: Optional[datetime]) -> Decimal:
+ if d_t is None:
+ return Decimal('NaN')
+ rate = self.exchange.historical_rate(self.ccy, d_t)
+ # Frequently there is no rate for today, until tomorrow :)
+ # Use spot quotes in that case
+ if rate.is_nan() and (datetime.today().date() - d_t.date()).days <= 2:
+ rate = self.exchange.get_cached_spot_quote(self.ccy)
+ self.history_used_spot = True
+ if rate is None:
+ rate = 'NaN'
+ return Decimal(rate)
+
+ def historical_value_str(self, satoshis, d_t: Optional[datetime]) -> str:
+ return self.format_fiat(self.historical_value(satoshis, d_t))
+
+ def historical_value(self, satoshis, d_t: Optional[datetime]) -> Decimal:
+ return self.fiat_value(satoshis, self.history_rate(d_t))
+
+ def timestamp_rate(self, timestamp: Optional[int]) -> Decimal:
+ from .util import timestamp_to_datetime
+ date = timestamp_to_datetime(timestamp)
+ return self.history_rate(date)
+
+
+assert globals().get(SimpleConfig.FX_EXCHANGE.get_default_value()), f"default exchange {SimpleConfig.FX_EXCHANGE.get_default_value()} does not exist"
diff --git a/electrum/fee_policy.py b/electrum/fee_policy.py
new file mode 100644
index 000000000000..b00f47696465
--- /dev/null
+++ b/electrum/fee_policy.py
@@ -0,0 +1,446 @@
+from typing import Optional, Sequence, Tuple, Union, TYPE_CHECKING, Dict
+from decimal import Decimal
+from numbers import Real
+from enum import IntEnum
+import math
+
+from .i18n import _
+from .util import NoDynamicFeeEstimates, quantize_feerate, format_fee_satoshis, FEERATE_PRECISION
+from . import util, constants
+from .logging import Logger
+
+if TYPE_CHECKING:
+ from .network import Network
+
+# 1008 = max conf target of core's estimatesmartfee, requesting more results in rpc error.
+# estimatesmartfee guarantees that the fee will get accepted into the mempool
+FEE_ETA_TARGETS = [1008, 144, 25, 10, 5, 2, 1]
+FEE_DEPTH_TARGETS = [10_000_000, 5_000_000, 2_000_000, 1_000_000,
+ 800_000, 600_000, 400_000, 250_000, 100_000]
+FEERATE_STATIC_VALUES = [1000, 2000, 5000, 10000, 20000, 30000,
+ 50000, 70000, 100000, 150000, 200000, 300000]
+
+# satoshi per kbyte
+FEERATE_MAX_DYNAMIC = 1500000
+FEERATE_WARNING_HIGH_FEE = 600000
+FEERATE_FALLBACK_STATIC_FEE = 150000
+FEERATE_REGTEST_STATIC_FEE = FEERATE_FALLBACK_STATIC_FEE # hardcoded fee used on regtest
+FEERATE_MIN_RELAY = 100
+FEERATE_DEFAULT_RELAY = 1000 # conservative "min relay fee"
+FEERATE_MAX_RELAY = 50000
+assert FEERATE_MIN_RELAY <= FEERATE_DEFAULT_RELAY <= FEERATE_MAX_RELAY
+
+# warn user if fee/amount for on-chain tx is higher than this
+FEE_RATIO_HIGH_WARNING = 0.05
+
+# note: make sure the network is asking for estimates for these targets
+FEE_LN_ETA_TARGET = 2
+FEE_LN_LOW_ETA_TARGET = 25
+FEE_LN_MINIMUM_ETA_TARGET = 1008
+
+
+# The min feerate_per_kw that can be used in lightning so that
+# the resulting onchain tx pays the min relay fee.
+# This would be FEERATE_DEFAULT_RELAY / 4 if not for rounding errors,
+# see https://github.com/ElementsProject/lightning/commit/2e687b9b352c9092b5e8bd4a688916ac50b44af0
+FEERATE_PER_KW_MIN_RELAY_LIGHTNING = 253
+
+
+def closest_index(value, array) -> int:
+ dist = list(map(lambda x: abs(x - value), array))
+ return min(range(len(dist)), key=dist.__getitem__)
+
+
+class FeeMethod(IntEnum):
+ # note: careful changing these names! they appear in the config files.
+ FIXED = 0 # fixed absolute fee
+ FEERATE = 1 # fixed fee rate
+ ETA = 2 # dynamic, ETA based
+ MEMPOOL = 3 # dynamic, mempool based
+
+ @classmethod
+ def slider_values(cls):
+ return [FeeMethod.FEERATE, FeeMethod.ETA, FeeMethod.MEMPOOL]
+
+ def name_for_GUI(self):
+ names = {
+ FeeMethod.FIXED: _('FIXED'),
+ FeeMethod.FEERATE: _('Feerate'),
+ FeeMethod.ETA: _('ETA'),
+ FeeMethod.MEMPOOL: _('Mempool')
+ }
+ return names[self]
+
+ @classmethod
+ def slider_index_of_method(cls, method):
+ try:
+ i = FeeMethod.slider_values().index(method)
+ except ValueError:
+ i = -1
+ return i
+
+
+class FeePolicy(Logger):
+ # object associated to a fee slider
+
+ def __init__(self, descriptor: str):
+ Logger.__init__(self)
+ try:
+ name, value = descriptor.split(':')
+ self.method = FeeMethod[name.upper()]
+ self.value = int(value) # target (e.g. num blocks, nbytes from mempool tip, sat/kbyte)
+ except Exception:
+ self.logger.warning(f"Could not parse fee policy descriptor '{descriptor}'. Falling back to 'eta:2'")
+ self.method = FeeMethod.ETA
+ self.value = 2
+
+ def __repr__(self):
+ return self.get_descriptor()
+
+ def get_descriptor(self) -> str:
+ return self.method.name.lower() + ':' + str(self.value)
+
+ def set_method(self, method: FeeMethod):
+ assert isinstance(method, FeeMethod)
+ self.method = method
+ # default values
+ if self.method == FeeMethod.MEMPOOL:
+ self.value = 1000000 # 1 mb from tip
+ elif self.method == FeeMethod.ETA:
+ self.value = 2 # 2 blocks
+ elif self.method == FeeMethod.FEERATE:
+ self.value = 5000 # sats per vkb
+ else:
+ self.value = 10 # sats
+
+ def _get_array(self) -> Sequence[int]:
+ if self.method == FeeMethod.MEMPOOL:
+ return FEE_DEPTH_TARGETS
+ elif self.method == FeeMethod.ETA:
+ return FEE_ETA_TARGETS
+ elif self.method == FeeMethod.FEERATE:
+ return FEERATE_STATIC_VALUES
+ else:
+ raise Exception('')
+
+ def set_value_from_slider_pos(self, slider_pos: int):
+ array = self._get_array()
+ slider_pos = max(0, min(slider_pos, len(array)-1))
+ self.value = array[slider_pos]
+
+ def get_slider_pos(self) -> int:
+ array = self._get_array()
+ return closest_index(self.value, array)
+
+ def get_slider_max(self) -> int:
+ array = self._get_array()
+ maxp = len(array) - 1
+ return maxp
+
+ @property
+ def use_dynamic_estimates(self):
+ return self.method in [FeeMethod.ETA, FeeMethod.MEMPOOL]
+
+ @classmethod
+ def depth_target(cls, slider_pos: int) -> int:
+ """Returns mempool depth target in bytes for a fee slider position."""
+ slider_pos = max(slider_pos, 0)
+ slider_pos = min(slider_pos, len(FEE_DEPTH_TARGETS)-1)
+ return FEE_DEPTH_TARGETS[slider_pos]
+
+ def eta_target(self, slider_pos: int) -> int:
+ """Returns 'num blocks' ETA target for a fee slider position."""
+ return FEE_ETA_TARGETS[slider_pos]
+
+ @classmethod
+ def eta_tooltip(cls, x):
+ if x < 0:
+ return _('Low fee')
+ elif x == 1:
+ return _('In the next block')
+ elif x == 144:
+ return _('Within one day')
+ elif x == 1008:
+ return _("Within one week")
+ else:
+ return _('Within {} blocks').format(x)
+
+ def get_target_text(self):
+ """ Description of what the target is: static fee / num blocks to confirm in / mempool depth """
+ if self.method == FeeMethod.ETA:
+ return self.eta_tooltip(self.value)
+ elif self.method == FeeMethod.MEMPOOL:
+ return self.depth_tooltip(self.value)
+ elif self.method == FeeMethod.FEERATE:
+ fee_per_byte = self.value/1000
+ return format_fee_satoshis(fee_per_byte) + f" {util.UI_UNIT_NAME_FEERATE_SAT_PER_VBYTE}"
+ elif self.method == FeeMethod.FIXED:
+ return f'{self.value} {util.UI_UNIT_NAME_FIXED_SAT}'
+
+ def get_estimate_text(self, network: 'Network') -> str:
+ """
+ Description of the current fee estimate corresponding to the target
+ """
+ fee_per_kb = self.fee_per_kb(network)
+ fee_per_byte = fee_per_kb/1000 if fee_per_kb is not None else None
+ tooltip = ''
+ if self.use_dynamic_estimates:
+ if fee_per_byte is not None:
+ tooltip = format_fee_satoshis(fee_per_byte) + f" {util.UI_UNIT_NAME_FEERATE_SAT_PER_VBYTE}"
+ elif self.method == FeeMethod.FEERATE:
+ assert fee_per_kb is not None
+ assert fee_per_byte is not None
+ if network and network.mempool_fees.has_data():
+ depth = network.mempool_fees.fee_to_depth(fee_per_byte)
+ tooltip = self.depth_tooltip(depth)
+ if network and network.fee_estimates.has_data():
+ eta = network.fee_estimates.fee_to_eta(fee_per_kb)
+ tooltip += '\n' + self.eta_tooltip(eta)
+ return tooltip
+
+ def get_tooltip(self, network: 'Network'):
+ target = self.get_target_text()
+ estimate = self.get_estimate_text(network)
+ if self.use_dynamic_estimates:
+ return _('Target') + ': ' + target + '\n' + _('Current rate') + ': ' + estimate
+ else:
+ return _('Fixed rate') + ': ' + target + '\n' + _('Estimate') + ': ' + estimate
+
+ @classmethod
+ def depth_tooltip(cls, depth: Optional[int]) -> str:
+ """Returns text tooltip for given mempool depth (in vbytes)."""
+ if depth is None:
+ return "unknown from tip"
+ depth_mb = cls.get_depth_mb_str(depth)
+ return _("{} from tip").format(depth_mb)
+
+ @classmethod
+ def get_depth_mb_str(cls, depth: int) -> str:
+ # e.g. 500_000 -> "0.50 MB"
+ depth_mb = "{:.2f}".format(depth / 1_000_000) # maybe .rstrip("0") ?
+ return f"{depth_mb} {util.UI_UNIT_NAME_MEMPOOL_MB}"
+
+ def fee_per_kb(self, network: 'Network') -> Optional[int]:
+ """Returns sat/kvB fee to pay for a txn.
+ Note: might return None.
+ """
+ if self.method == FeeMethod.FEERATE:
+ fee_rate = self.value
+ elif self.method == FeeMethod.MEMPOOL:
+ if network:
+ fee_rate = network.mempool_fees.depth_to_fee(self.get_slider_pos())
+ else:
+ fee_rate = None
+ elif self.method == FeeMethod.ETA:
+ if network:
+ fee_rate = network.fee_estimates.eta_to_fee(self.get_slider_pos())
+ else:
+ fee_rate = None
+ elif self.method == FeeMethod.FIXED:
+ fee_rate = None
+ else:
+ raise Exception(self.method)
+ if fee_rate is not None:
+ fee_rate = int(fee_rate)
+ return fee_rate
+
+ def fee_per_byte(self, network: 'Network') -> Optional[int]:
+ """Returns sat/vB fee to pay for a txn.
+ Note: might return None.
+ """
+ fee_per_kb = self.fee_per_kb(network)
+ return fee_per_kb / 1000 if fee_per_kb is not None else None
+
+ def estimate_fee(
+ self, size: Union[int, float, Decimal], *,
+ network: 'Network' = None,
+ allow_fallback_to_static_rates: bool = False,
+ ) -> int:
+ if self.method == FeeMethod.FIXED:
+ return self.value
+ fee_per_kb = self.fee_per_kb(network)
+ if fee_per_kb is None and self.use_dynamic_estimates:
+ if allow_fallback_to_static_rates:
+ fee_per_kb = FEERATE_FALLBACK_STATIC_FEE
+ else:
+ raise NoDynamicFeeEstimates()
+
+ return self.estimate_fee_for_feerate(fee_per_kb=fee_per_kb, size=size)
+
+ @classmethod
+ def estimate_fee_for_feerate(
+ cls,
+ *,
+ fee_per_kb: Union[int, float, Decimal],
+ size: Union[int, float, Decimal],
+ ) -> int:
+ # note: 'size' is in vbytes
+ size = Decimal(size)
+ fee_per_kb = Decimal(fee_per_kb)
+ fee_per_byte = fee_per_kb / 1000
+ # to be consistent with what is displayed in the GUI,
+ # the calculation needs to use the same precision:
+ fee_per_byte = quantize_feerate(fee_per_byte)
+ return math.ceil(fee_per_byte * size)
+
+
+class FixedFeePolicy(FeePolicy):
+ def __init__(self, fee):
+ FeePolicy.__init__(self, 'fixed:%d' % fee)
+
+
+def impose_hard_limits_on_fee(func):
+ def get_fee_within_limits(self, *args, **kwargs):
+ fee = func(self, *args, **kwargs)
+ if fee is None:
+ return fee
+ fee = min(FEERATE_MAX_DYNAMIC, fee)
+ # Clamp dynamic feerates with conservative min relay fee,
+ # to ensure txs propagate well:
+ fee = max(FEERATE_DEFAULT_RELAY, fee)
+ return fee
+ return get_fee_within_limits
+
+
+class FeeHistogram:
+
+ def __init__(self):
+ self._data = None # type: Optional[Sequence[Tuple[Union[float, int], int]]]
+
+ def has_data(self) -> bool:
+ return self._data is not None
+
+ def set_data(self, data):
+ self._data = data
+
+ def fee_to_depth(self, target_fee: Real) -> Optional[int]:
+ """For a given sat/vbyte fee, returns an estimate of how deep
+ it would be in the current mempool in vbytes.
+ Pessimistic == overestimates the depth.
+ """
+ if self._data is None:
+ return None
+ depth = 0
+ for fee, s in self._data:
+ depth += s
+ if fee <= target_fee:
+ break
+ return depth
+
+ @impose_hard_limits_on_fee
+ def depth_target_to_fee(self, target: int) -> Optional[int]:
+ """Returns fee in sat/kbyte.
+ target: desired mempool depth in vbytes
+ """
+ if self._data is None:
+ return None
+ depth = 0
+ for fee, s in self._data:
+ depth += s
+ if depth > target:
+ break
+ else:
+ return 0
+ # add one sat/byte as currently that is the max precision of the histogram
+ # note: precision depends on server.
+ # old ElectrumX <1.16 has 1 s/b prec, >=1.16 has 0.1 s/b prec.
+ # electrs seems to use untruncated double-precision floating points.
+ # # TODO decrease this to 0.1 s/b next time we bump the required protocol version
+ fee += 1
+ # convert to sat/kbyte
+ return int(fee * 1000)
+
+ def depth_to_fee(self, slider_pos) -> Optional[int]:
+ """Returns fee in sat/kbyte."""
+ target = FeePolicy.depth_target(slider_pos)
+ return self.depth_target_to_fee(target)
+
+ def get_capped_data(self):
+ """ used by QML """
+ data = self._data or [[FEERATE_DEFAULT_RELAY/1000, 1]]
+ # cap the histogram to a limited number of megabytes
+ bytes_limit = 10*1000*1000
+ bytes_current = 0
+ capped_histogram = []
+ for item in sorted(data, key=lambda x: x[0], reverse=True):
+ if bytes_current >= bytes_limit:
+ break
+ slot = min(item[1], bytes_limit - bytes_current)
+ bytes_current += slot
+ # round & limit precision
+ value = int(item[0] * 10**FEERATE_PRECISION) / 10**FEERATE_PRECISION
+ capped_histogram.append([
+ max(FEERATE_MIN_RELAY/1000, value), # clamped to [FEERATE_MIN_RELAY/1000, inf)
+ slot, # width of bucket
+ bytes_current, # cumulative depth at far end of bucket
+ ])
+ return capped_histogram, bytes_current
+
+
+class FeeTimeEstimates:
+
+ def __init__(self):
+ self.data = {} # type: Dict[int, int]
+
+ def get_data(self):
+ return self.data
+
+ def has_data(self) -> bool:
+ """Returns if we have estimates for *all* targets requested.
+ Note: if wanting an estimate for a specific target, instead of checking has_data(),
+ just try to do the estimate and handle a potential None result. That way,
+ estimation works for targets we have, even if some targets are missing.
+ """
+ targets = set(FEE_ETA_TARGETS)
+ targets.discard(1) # rm "next block" target
+ return all(target in self.data for target in targets)
+
+ def set_data(self, nblock_target: int, fee_per_kb: int):
+ assert isinstance(nblock_target, int), f"expected int, got {nblock_target!r}"
+ assert isinstance(fee_per_kb, int), f"expected int, got {fee_per_kb!r}"
+ self.data[nblock_target] = fee_per_kb
+
+ def fee_to_eta(self, fee_per_kb: Optional[int]) -> int:
+ """Returns 'num blocks' ETA estimate for given fee rate,
+ or -1 for low fee.
+ """
+ import operator
+ lst = list(self.data.items())
+ next_block_fee = self.eta_target_to_fee(1)
+ if next_block_fee is not None:
+ lst += [(1, next_block_fee)]
+ if not lst or fee_per_kb is None:
+ return -1
+ dist = map(lambda x: (x[0], abs(x[1] - fee_per_kb)), lst)
+ min_target, min_value = min(dist, key=operator.itemgetter(1))
+ if fee_per_kb < self.data.get(FEE_ETA_TARGETS[0])/2:
+ min_target = -1
+ return min_target
+
+ def eta_to_fee(self, slider_pos) -> Optional[int]:
+ """Returns fee in sat/kbyte."""
+ slider_pos = max(slider_pos, 0)
+ slider_pos = min(slider_pos, len(FEE_ETA_TARGETS) - 1)
+ if slider_pos < len(FEE_ETA_TARGETS) - 1:
+ num_blocks = FEE_ETA_TARGETS[int(slider_pos)]
+ fee = self.eta_target_to_fee(num_blocks)
+ else:
+ fee = self.eta_target_to_fee(1)
+ return fee
+
+ @impose_hard_limits_on_fee
+ def eta_target_to_fee(self, num_blocks: int) -> Optional[int]:
+ """Returns fee in sat/kbyte."""
+ if num_blocks == 1:
+ fee = self.data.get(2)
+ if fee is not None:
+ fee += fee / 2
+ fee = int(fee)
+ else:
+ fee = self.data.get(num_blocks)
+ if fee is not None:
+ fee = int(fee)
+ # fallback for regtest
+ if fee is None and constants.net is constants.BitcoinRegtest:
+ return FEERATE_REGTEST_STATIC_FEE
+ return fee
diff --git a/electrum/gui/__init__.py b/electrum/gui/__init__.py
new file mode 100644
index 000000000000..dfd309998928
--- /dev/null
+++ b/electrum/gui/__init__.py
@@ -0,0 +1,33 @@
+# To create a new GUI, please add its code to this directory.
+# Three objects are passed to the ElectrumGui: config, daemon and plugins
+# The Wallet object is instantiated by the GUI
+
+# Notifications about network events are sent to the GUI by using network.register_callback()
+
+from typing import TYPE_CHECKING, Mapping, Optional
+
+if TYPE_CHECKING:
+ from . import qt
+ from electrum.simple_config import SimpleConfig
+ from electrum.daemon import Daemon
+ from electrum.plugin import Plugins
+
+
+class BaseElectrumGui:
+ def __init__(self, *, config: 'SimpleConfig', daemon: 'Daemon', plugins: 'Plugins'):
+ self.config = config
+ self.daemon = daemon
+ self.plugins = plugins
+
+ def main(self) -> None:
+ raise NotImplementedError()
+
+ def stop(self) -> None:
+ """Stops the GUI.
+ This method must be thread-safe.
+ """
+ pass
+
+ @classmethod
+ def version_info(cls) -> Mapping[str, Optional[str]]:
+ return {}
diff --git a/electrum/gui/common_qt/__init__.py b/electrum/gui/common_qt/__init__.py
new file mode 100644
index 000000000000..97c1f5bae897
--- /dev/null
+++ b/electrum/gui/common_qt/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (C) 2023 The Electrum developers
+# Distributed under the MIT software license, see the accompanying
+# file LICENCE or http://www.opensource.org/licenses/mit-license.php
+
diff --git a/electrum/gui/common_qt/i18n.py b/electrum/gui/common_qt/i18n.py
new file mode 100644
index 000000000000..c0de7dab732d
--- /dev/null
+++ b/electrum/gui/common_qt/i18n.py
@@ -0,0 +1,17 @@
+from PyQt6.QtCore import QTranslator
+
+from electrum.i18n import _
+
+
+class ElectrumTranslator(QTranslator):
+ """Delegator for Qt translations to gettext"""
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ # explicit enumeration of translatable strings from Qt standard library, so these
+ # will be included in the electrum gettext translation template
+ self._strings = [_('&Undo'), _('&Redo'), _('Cu&t'), _('&Copy'), _('&Paste'), _('Select All'),
+ _('Copy &Link Location')]
+
+ def translate(self, context, source_text: str, disambiguation, n):
+ return _(source_text, context=context)
diff --git a/electrum/gui/common_qt/plugins.py b/electrum/gui/common_qt/plugins.py
new file mode 100644
index 000000000000..a182306d5436
--- /dev/null
+++ b/electrum/gui/common_qt/plugins.py
@@ -0,0 +1,50 @@
+import sys
+from typing import TYPE_CHECKING, Optional
+
+from PyQt6.QtCore import pyqtSignal, pyqtProperty, QObject
+
+from electrum.logging import get_logger
+
+if TYPE_CHECKING:
+ from electrum.gui.qml import ElectrumQmlApplication
+ from electrum.plugin import BasePlugin
+
+
+class PluginQObject(QObject):
+ logger = get_logger(__name__)
+
+ pluginChanged = pyqtSignal()
+ busyChanged = pyqtSignal()
+ pluginEnabledChanged = pyqtSignal()
+
+ def __init__(self, plugin: 'BasePlugin', parent: Optional['ElectrumQmlApplication']):
+ super().__init__(parent)
+
+ self._busy = False
+
+ self.plugin = plugin
+ self.app = parent
+
+ @pyqtProperty(str, notify=pluginChanged)
+ def name(self): return self._name
+
+ @pyqtProperty(bool, notify=busyChanged)
+ def busy(self): return self._busy
+
+ # below only used for QML, not compatible yet with Qt
+
+ @pyqtProperty(bool, notify=pluginEnabledChanged)
+ def pluginEnabled(self): return self.plugin.is_enabled()
+
+ @pluginEnabled.setter
+ def pluginEnabled(self, enabled):
+ if enabled != self.plugin.is_enabled():
+ self.logger.debug(f'can {self.plugin.can_user_disable()}, {self.plugin.is_available()}')
+ if not self.plugin.can_user_disable() and not enabled:
+ return
+ if enabled:
+ self.app.plugins.enable(self.plugin.name)
+ else:
+ self.app.plugins.disable(self.plugin.name)
+ self.pluginEnabledChanged.emit()
+
diff --git a/electrum/gui/common_qt/util.py b/electrum/gui/common_qt/util.py
new file mode 100644
index 000000000000..b28e0c69b67a
--- /dev/null
+++ b/electrum/gui/common_qt/util.py
@@ -0,0 +1,224 @@
+import queue
+import sys
+from functools import wraps
+from typing import Optional, NamedTuple, Callable
+import os.path
+
+from PyQt6 import QtGui
+from PyQt6.QtCore import Qt, QThread, pyqtSignal
+from PyQt6.QtGui import QColor, QPen, QPaintDevice, QFontDatabase, QImage
+import qrcode
+
+from electrum.i18n import _
+from electrum.logging import Logger
+from electrum.util import EventListener, event_listener
+
+_cached_font_ids: dict[str, int] = {}
+
+
+def get_font_id(filename: str) -> int:
+ font_id = _cached_font_ids.get(filename)
+ if font_id is not None:
+ return font_id
+ # font_id will be negative on error
+ font_id = QFontDatabase.addApplicationFont(
+ os.path.join(os.path.dirname(__file__), '..', 'fonts', filename)
+ )
+ _cached_font_ids[filename] = font_id
+ return font_id
+
+
+def draw_qr(
+ *,
+ qr: Optional[qrcode.main.QRCode],
+ paint_device: QPaintDevice, # target to paint on
+ is_enabled: bool = True,
+ min_boxsize: int = 2, # min size in pixels of single black/white unit box of the qr code
+) -> None:
+ """Draw 'qr' onto 'paint_device'.
+ - qr.box_size is ignored. We will calculate our own boxsize to fill the whole size of paint_device.
+ - qr.border is respected.
+ """
+ black = QColor(0, 0, 0, 255)
+ grey = QColor(196, 196, 196, 255)
+ white = QColor(255, 255, 255, 255)
+ black_pen = QPen(black) if is_enabled else QPen(grey)
+ black_pen.setJoinStyle(Qt.PenJoinStyle.MiterJoin)
+
+ if not qr:
+ qp = QtGui.QPainter()
+ qp.begin(paint_device)
+ qp.setBrush(white)
+ qp.setPen(white)
+ r = qp.viewport()
+ qp.drawRect(0, 0, r.width(), r.height())
+ qp.end()
+ return
+
+ # note: next line can raise qrcode.exceptions.DataOverflowError (or ValueError)
+ matrix = qr.get_matrix() # includes qr.border
+ k = len(matrix)
+ qp = QtGui.QPainter()
+ qp.begin(paint_device)
+ r = qp.viewport()
+ framesize = min(r.width(), r.height())
+ boxsize = int(framesize / k)
+ if boxsize < min_boxsize:
+ # The amount of data is still within what can fit into a QR code,
+ # however we don't have enough pixels to draw it.
+ qp.setBrush(white)
+ qp.setPen(white)
+ qp.drawRect(0, 0, r.width(), r.height())
+ qp.setBrush(black)
+ qp.setPen(black)
+ qp.drawText(0, 20, _("Cannot draw QR code") + ":")
+ qp.drawText(0, 40, _("Not enough space available."))
+ qp.end()
+ return
+ size = k * boxsize
+ left = (framesize - size) / 2
+ top = (framesize - size) / 2
+ # Draw white background with margin
+ qp.setBrush(white)
+ qp.setPen(white)
+ qp.drawRect(0, 0, framesize, framesize)
+ # Draw qr code
+ qp.setBrush(black if is_enabled else grey)
+ qp.setPen(black_pen)
+ for r in range(k):
+ for c in range(k):
+ if matrix[r][c]:
+ qp.drawRect(
+ int(left + c * boxsize), int(top + r * boxsize),
+ boxsize - 1, boxsize - 1)
+ qp.end()
+
+
+def paintQR(data) -> Optional[QImage]:
+ if not data:
+ return None
+
+ # Create QR code
+ qr = qrcode.QRCode()
+ qr.add_data(data)
+
+ # Create a QImage to draw on
+ matrix = qr.get_matrix()
+ k = len(matrix)
+ boxsize = 5
+ size = k * boxsize
+
+ # Create the image with appropriate size
+ base_img = QImage(size, size, QImage.Format.Format_ARGB32)
+
+ # Use draw_qr to paint on the image
+ draw_qr(
+ qr=qr,
+ paint_device=base_img,
+ is_enabled=True,
+ min_boxsize=boxsize
+ )
+
+ return base_img
+
+
+class TaskThread(QThread, Logger):
+ """Thread that runs background tasks. Callbacks are guaranteed
+ to happen in the context of its parent."""
+
+ class Task(NamedTuple):
+ task: Callable
+ cb_success: Optional[Callable]
+ cb_done: Optional[Callable]
+ cb_error: Optional[Callable]
+ cancel: Optional[Callable] = None
+
+ doneSig = pyqtSignal(object, object, object)
+
+ def __init__(self, parent, on_error=None):
+ QThread.__init__(self, parent)
+ Logger.__init__(self)
+ self.on_error = on_error
+ self.tasks = queue.Queue()
+ self._cur_task = None # type: Optional[TaskThread.Task]
+ self._stopping = False
+ self.doneSig.connect(self.on_done)
+ self.start()
+
+ def add(self, task, on_success=None, on_done=None, on_error=None, *, cancel=None):
+ if self._stopping:
+ self.logger.warning(f"stopping or already stopped but tried to add new task.")
+ return
+ on_error = on_error or self.on_error
+ task_ = TaskThread.Task(task, on_success, on_done, on_error, cancel=cancel)
+ self.tasks.put(task_)
+
+ def run(self):
+ while True:
+ if self._stopping:
+ break
+ task = self.tasks.get() # type: TaskThread.Task
+ self._cur_task = task
+ if not task or self._stopping:
+ break
+ try:
+ result = task.task()
+ self.doneSig.emit(result, task.cb_done, task.cb_success)
+ except BaseException:
+ self.doneSig.emit(sys.exc_info(), task.cb_done, task.cb_error)
+
+ def on_done(self, result, cb_done, cb_result):
+ # This runs in the parent's thread.
+ if cb_done:
+ cb_done()
+ if cb_result:
+ cb_result(result)
+
+ def stop(self):
+ self._stopping = True
+ # try to cancel currently running task now.
+ # if the task does not implement "cancel", we will have to wait until it finishes.
+ task = self._cur_task
+ if task and task.cancel:
+ task.cancel()
+ # cancel the remaining tasks in the queue
+ while True:
+ try:
+ task = self.tasks.get_nowait()
+ except queue.Empty:
+ break
+ if task and task.cancel:
+ task.cancel()
+ self.tasks.put(None) # in case the thread is still waiting on the queue
+ self.exit()
+ self.wait()
+
+
+class QtEventListener(EventListener):
+ qt_callback_signal = pyqtSignal(tuple)
+
+ def register_callbacks(self):
+ self.qt_callback_signal.connect(self.on_qt_callback_signal)
+ EventListener.register_callbacks(self)
+
+ def unregister_callbacks(self):
+ try:
+ self.qt_callback_signal.disconnect()
+ except (RuntimeError, TypeError): # wrapped Qt object might be deleted
+ # "TypeError: disconnect() failed between 'qt_callback_signal' and all its connections"
+ pass
+ EventListener.unregister_callbacks(self)
+
+ def on_qt_callback_signal(self, args):
+ func = args[0]
+ return func(self, *args[1:])
+
+
+# decorator for members of the QtEventListener class
+def qt_event_listener(func):
+ func = event_listener(func)
+
+ @wraps(func)
+ def decorator(self, *args):
+ self.qt_callback_signal.emit((func,) + args)
+ return decorator
diff --git a/electrum/gui/default_lang.py b/electrum/gui/default_lang.py
new file mode 100644
index 000000000000..3528b8771130
--- /dev/null
+++ b/electrum/gui/default_lang.py
@@ -0,0 +1,34 @@
+# Copyright (C) 2023 The Electrum developers
+# Distributed under the MIT software license, see the accompanying
+# file LICENCE or http://www.opensource.org/licenses/mit-license.php
+#
+# Note: try not to import modules from electrum, or at least from GUIs.
+# This is to avoid evaluating module-level string-translations before we get
+# a chance to set the default language.
+
+import os
+from typing import Optional
+
+from electrum.i18n import languages
+
+
+jLocale = None
+if "ANDROID_DATA" in os.environ:
+ from jnius import autoclass, cast
+ jLocale = autoclass("java.util.Locale")
+
+
+def get_default_language(*, gui_name: Optional[str] = None) -> str:
+ if gui_name == "qt":
+ from PyQt6.QtCore import QLocale
+ name = QLocale.system().name()
+ return name if name in languages else "en_UK"
+ elif gui_name == "qml":
+ from PyQt6.QtCore import QLocale
+ # On Android QLocale does not return the system locale
+ try:
+ name = str(jLocale.getDefault().toString())
+ except Exception:
+ name = QLocale.system().name()
+ return name if name in languages else "en_GB"
+ return ""
diff --git a/electrum/gui/fonts/PTMono-Bold.ttf b/electrum/gui/fonts/PTMono-Bold.ttf
new file mode 100644
index 000000000000..b1a145e0e80e
Binary files /dev/null and b/electrum/gui/fonts/PTMono-Bold.ttf differ
diff --git a/electrum/gui/fonts/PTMono-Regular.ttf b/electrum/gui/fonts/PTMono-Regular.ttf
new file mode 100644
index 000000000000..b1983838c662
Binary files /dev/null and b/electrum/gui/fonts/PTMono-Regular.ttf differ
diff --git a/electrum/gui/fonts/PTMono.LICENSE b/electrum/gui/fonts/PTMono.LICENSE
new file mode 100644
index 000000000000..c15a6c289140
--- /dev/null
+++ b/electrum/gui/fonts/PTMono.LICENSE
@@ -0,0 +1,94 @@
+Copyright (c) 2011, ParaType Ltd. (http://www.paratype.com/public),
+with Reserved Font Names "PT Sans", "PT Serif", "PT Mono" and "ParaType".
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/electrum/gui/icons/Electrum_512.png b/electrum/gui/icons/Electrum_512.png
new file mode 100644
index 000000000000..ca24d5f45f6b
Binary files /dev/null and b/electrum/gui/icons/Electrum_512.png differ
diff --git a/electrum/gui/icons/Electrum_square_512.png b/electrum/gui/icons/Electrum_square_512.png
new file mode 100644
index 000000000000..fa2ea7010387
Binary files /dev/null and b/electrum/gui/icons/Electrum_square_512.png differ
diff --git a/electrum/gui/icons/add.png b/electrum/gui/icons/add.png
new file mode 100644
index 000000000000..2de908118dea
Binary files /dev/null and b/electrum/gui/icons/add.png differ
diff --git a/electrum/gui/icons/android_electrum_icon_background.png b/electrum/gui/icons/android_electrum_icon_background.png
new file mode 100644
index 000000000000..81da07263ca1
Binary files /dev/null and b/electrum/gui/icons/android_electrum_icon_background.png differ
diff --git a/electrum/gui/icons/android_electrum_icon_foreground.png b/electrum/gui/icons/android_electrum_icon_foreground.png
new file mode 100644
index 000000000000..b6bb5b14963d
Binary files /dev/null and b/electrum/gui/icons/android_electrum_icon_foreground.png differ
diff --git a/electrum/gui/icons/android_electrum_icon_legacy.png b/electrum/gui/icons/android_electrum_icon_legacy.png
new file mode 100644
index 000000000000..40d9678556bd
Binary files /dev/null and b/electrum/gui/icons/android_electrum_icon_legacy.png differ
diff --git a/electrum/gui/icons/bitcoin.png b/electrum/gui/icons/bitcoin.png
new file mode 100644
index 000000000000..a597e86135c8
Binary files /dev/null and b/electrum/gui/icons/bitcoin.png differ
diff --git a/electrum/gui/icons/bookmark.png b/electrum/gui/icons/bookmark.png
new file mode 100644
index 000000000000..dd24a0c15212
Binary files /dev/null and b/electrum/gui/icons/bookmark.png differ
diff --git a/electrum/gui/icons/bookmark.svg b/electrum/gui/icons/bookmark.svg
new file mode 100644
index 000000000000..32123634dadc
--- /dev/null
+++ b/electrum/gui/icons/bookmark.svg
@@ -0,0 +1,11 @@
+
+
+
\ No newline at end of file
diff --git a/electrum/gui/icons/bookmark_add.png b/electrum/gui/icons/bookmark_add.png
new file mode 100644
index 000000000000..430861c479c3
Binary files /dev/null and b/electrum/gui/icons/bookmark_add.png differ
diff --git a/electrum/gui/icons/bookmark_add.svg b/electrum/gui/icons/bookmark_add.svg
new file mode 100644
index 000000000000..ed4b981d4b45
--- /dev/null
+++ b/electrum/gui/icons/bookmark_add.svg
@@ -0,0 +1,23 @@
+
+
+
\ No newline at end of file
diff --git a/electrum/gui/icons/bookmark_remove.png b/electrum/gui/icons/bookmark_remove.png
new file mode 100644
index 000000000000..a75c86c6b376
Binary files /dev/null and b/electrum/gui/icons/bookmark_remove.png differ
diff --git a/electrum/gui/icons/bookmark_remove.svg b/electrum/gui/icons/bookmark_remove.svg
new file mode 100644
index 000000000000..a6036a19fa54
--- /dev/null
+++ b/electrum/gui/icons/bookmark_remove.svg
@@ -0,0 +1,23 @@
+
+
+
\ No newline at end of file
diff --git a/electrum/gui/icons/bug.png b/electrum/gui/icons/bug.png
new file mode 100644
index 000000000000..0699a1abf2e9
Binary files /dev/null and b/electrum/gui/icons/bug.png differ
diff --git a/electrum/gui/icons/camera_dark.png b/electrum/gui/icons/camera_dark.png
new file mode 100644
index 000000000000..c2b73f70c729
Binary files /dev/null and b/electrum/gui/icons/camera_dark.png differ
diff --git a/gui/kivy/theming/light/camera.png b/electrum/gui/icons/camera_white.png
similarity index 100%
rename from gui/kivy/theming/light/camera.png
rename to electrum/gui/icons/camera_white.png
diff --git a/electrum/gui/icons/chevron-right.png b/electrum/gui/icons/chevron-right.png
new file mode 100644
index 000000000000..97ebfb0b4bf1
Binary files /dev/null and b/electrum/gui/icons/chevron-right.png differ
diff --git a/electrum/gui/icons/clock1.png b/electrum/gui/icons/clock1.png
new file mode 100644
index 000000000000..4e6b04d70543
Binary files /dev/null and b/electrum/gui/icons/clock1.png differ
diff --git a/electrum/gui/icons/clock2.png b/electrum/gui/icons/clock2.png
new file mode 100644
index 000000000000..ac47a29519e5
Binary files /dev/null and b/electrum/gui/icons/clock2.png differ
diff --git a/electrum/gui/icons/clock3.png b/electrum/gui/icons/clock3.png
new file mode 100644
index 000000000000..daa4a9f5d8a1
Binary files /dev/null and b/electrum/gui/icons/clock3.png differ
diff --git a/electrum/gui/icons/clock4.png b/electrum/gui/icons/clock4.png
new file mode 100644
index 000000000000..413935424b77
Binary files /dev/null and b/electrum/gui/icons/clock4.png differ
diff --git a/electrum/gui/icons/clock5.pdn b/electrum/gui/icons/clock5.pdn
new file mode 100644
index 000000000000..e2676e6e7287
Binary files /dev/null and b/electrum/gui/icons/clock5.pdn differ
diff --git a/electrum/gui/icons/clock5.png b/electrum/gui/icons/clock5.png
new file mode 100644
index 000000000000..64e1f74c1e42
Binary files /dev/null and b/electrum/gui/icons/clock5.png differ
diff --git a/electrum/gui/icons/closebutton.png b/electrum/gui/icons/closebutton.png
new file mode 100644
index 000000000000..ecd8a0de4b3b
Binary files /dev/null and b/electrum/gui/icons/closebutton.png differ
diff --git a/electrum/gui/icons/cloud_no.png b/electrum/gui/icons/cloud_no.png
new file mode 100644
index 000000000000..9034821e87ad
Binary files /dev/null and b/electrum/gui/icons/cloud_no.png differ
diff --git a/electrum/gui/icons/cloud_yes.png b/electrum/gui/icons/cloud_yes.png
new file mode 100644
index 000000000000..dd30292affbf
Binary files /dev/null and b/electrum/gui/icons/cloud_yes.png differ
diff --git a/electrum/gui/icons/confirmed.png b/electrum/gui/icons/confirmed.png
new file mode 100644
index 000000000000..2023abd15e2b
Binary files /dev/null and b/electrum/gui/icons/confirmed.png differ
diff --git a/electrum/gui/icons/confirmed.svg b/electrum/gui/icons/confirmed.svg
new file mode 100644
index 000000000000..710b3f8c34d2
--- /dev/null
+++ b/electrum/gui/icons/confirmed.svg
@@ -0,0 +1,44 @@
+
+
+
+
diff --git a/gui/kivy/theming/light/confirmed.png b/electrum/gui/icons/confirmed_bw.png
similarity index 100%
rename from gui/kivy/theming/light/confirmed.png
rename to electrum/gui/icons/confirmed_bw.png
diff --git a/icons/copy.png b/electrum/gui/icons/copy.png
similarity index 100%
rename from icons/copy.png
rename to electrum/gui/icons/copy.png
diff --git a/electrum/gui/icons/copy_bw.png b/electrum/gui/icons/copy_bw.png
new file mode 100644
index 000000000000..50d75729cad7
Binary files /dev/null and b/electrum/gui/icons/copy_bw.png differ
diff --git a/electrum/gui/icons/delete.png b/electrum/gui/icons/delete.png
new file mode 100644
index 000000000000..02a7d58bac96
Binary files /dev/null and b/electrum/gui/icons/delete.png differ
diff --git a/electrum.icns b/electrum/gui/icons/electrum.icns
similarity index 100%
rename from electrum.icns
rename to electrum/gui/icons/electrum.icns
diff --git a/electrum/gui/icons/electrum.ico b/electrum/gui/icons/electrum.ico
new file mode 100644
index 000000000000..3baebbce3c64
Binary files /dev/null and b/electrum/gui/icons/electrum.ico differ
diff --git a/electrum/gui/icons/electrum.png b/electrum/gui/icons/electrum.png
new file mode 100644
index 000000000000..2ab3260e042b
Binary files /dev/null and b/electrum/gui/icons/electrum.png differ
diff --git a/icons/electrum_dark_icon.png b/electrum/gui/icons/electrum_dark_icon.png
similarity index 100%
rename from icons/electrum_dark_icon.png
rename to electrum/gui/icons/electrum_dark_icon.png
diff --git a/electrum/gui/icons/electrum_darkblue.svg b/electrum/gui/icons/electrum_darkblue.svg
new file mode 100644
index 000000000000..0c521fbc5a08
--- /dev/null
+++ b/electrum/gui/icons/electrum_darkblue.svg
@@ -0,0 +1,199 @@
+
+
diff --git a/electrum/gui/icons/electrum_darkblue_1.png b/electrum/gui/icons/electrum_darkblue_1.png
new file mode 100644
index 000000000000..199e5993cc47
Binary files /dev/null and b/electrum/gui/icons/electrum_darkblue_1.png differ
diff --git a/icons/electrum_launcher.png b/electrum/gui/icons/electrum_launcher.png
similarity index 100%
rename from icons/electrum_launcher.png
rename to electrum/gui/icons/electrum_launcher.png
diff --git a/icons/electrum_light_icon.png b/electrum/gui/icons/electrum_light_icon.png
similarity index 100%
rename from icons/electrum_light_icon.png
rename to electrum/gui/icons/electrum_light_icon.png
diff --git a/electrum/gui/icons/electrum_lightblue.svg b/electrum/gui/icons/electrum_lightblue.svg
new file mode 100644
index 000000000000..d62578b976e3
--- /dev/null
+++ b/electrum/gui/icons/electrum_lightblue.svg
@@ -0,0 +1,193 @@
+
+
diff --git a/electrum/gui/icons/electrum_presplash.png b/electrum/gui/icons/electrum_presplash.png
new file mode 100644
index 000000000000..3ac656510b8c
Binary files /dev/null and b/electrum/gui/icons/electrum_presplash.png differ
diff --git a/electrum/gui/icons/electrum_text.png b/electrum/gui/icons/electrum_text.png
new file mode 100644
index 000000000000..546381f28ab6
Binary files /dev/null and b/electrum/gui/icons/electrum_text.png differ
diff --git a/electrum/gui/icons/electrumb.png b/electrum/gui/icons/electrumb.png
new file mode 100644
index 000000000000..efa26486e495
Binary files /dev/null and b/electrum/gui/icons/electrumb.png differ
diff --git a/icons/expired.png b/electrum/gui/icons/expired.png
similarity index 100%
rename from icons/expired.png
rename to electrum/gui/icons/expired.png
diff --git a/electrum/gui/icons/eye1.png b/electrum/gui/icons/eye1.png
new file mode 100644
index 000000000000..0bded339f0f4
Binary files /dev/null and b/electrum/gui/icons/eye1.png differ
diff --git a/electrum/gui/icons/file.png b/electrum/gui/icons/file.png
new file mode 100644
index 000000000000..ae84e2599331
Binary files /dev/null and b/electrum/gui/icons/file.png differ
diff --git a/electrum/gui/icons/freeze.png b/electrum/gui/icons/freeze.png
new file mode 100644
index 000000000000..8e28cc2e0171
Binary files /dev/null and b/electrum/gui/icons/freeze.png differ
diff --git a/gui/kivy/theming/light/globe.png b/electrum/gui/icons/globe.png
similarity index 100%
rename from gui/kivy/theming/light/globe.png
rename to electrum/gui/icons/globe.png
diff --git a/electrum/gui/icons/hamburger.png b/electrum/gui/icons/hamburger.png
new file mode 100644
index 000000000000..4240b6635bd7
Binary files /dev/null and b/electrum/gui/icons/hamburger.png differ
diff --git a/electrum/gui/icons/hd.png b/electrum/gui/icons/hd.png
new file mode 100644
index 000000000000..1d58a3645f80
Binary files /dev/null and b/electrum/gui/icons/hd.png differ
diff --git a/electrum/gui/icons/hd_white.png b/electrum/gui/icons/hd_white.png
new file mode 100644
index 000000000000..570520307b4b
Binary files /dev/null and b/electrum/gui/icons/hd_white.png differ
diff --git a/electrum/gui/icons/info.png b/electrum/gui/icons/info.png
new file mode 100644
index 000000000000..f11f99694aae
Binary files /dev/null and b/electrum/gui/icons/info.png differ
diff --git a/electrum/gui/icons/kangaroo.png b/electrum/gui/icons/kangaroo.png
new file mode 100644
index 000000000000..e27d4f81b724
Binary files /dev/null and b/electrum/gui/icons/kangaroo.png differ
diff --git a/icons/key.png b/electrum/gui/icons/key.png
similarity index 100%
rename from icons/key.png
rename to electrum/gui/icons/key.png
diff --git a/electrum/gui/icons/lightning.png b/electrum/gui/icons/lightning.png
new file mode 100644
index 000000000000..797d3ca93816
Binary files /dev/null and b/electrum/gui/icons/lightning.png differ
diff --git a/electrum/gui/icons/lightning_disconnected.png b/electrum/gui/icons/lightning_disconnected.png
new file mode 100644
index 000000000000..cf71ceeab730
Binary files /dev/null and b/electrum/gui/icons/lightning_disconnected.png differ
diff --git a/electrum/gui/icons/link.png b/electrum/gui/icons/link.png
new file mode 100644
index 000000000000..af462bc202f3
Binary files /dev/null and b/electrum/gui/icons/link.png differ
diff --git a/electrum/gui/icons/lock.png b/electrum/gui/icons/lock.png
new file mode 100644
index 000000000000..a190964ed231
Binary files /dev/null and b/electrum/gui/icons/lock.png differ
diff --git a/icons/lock.svg b/electrum/gui/icons/lock.svg
similarity index 99%
rename from icons/lock.svg
rename to electrum/gui/icons/lock.svg
index 0473d192149c..9024d2a37634 100644
--- a/icons/lock.svg
+++ b/electrum/gui/icons/lock.svg
@@ -6,8 +6,9 @@
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
version="1.0"
- width="22"
- height="22"
+ width="512"
+ height="512"
+ viewBox="0 0 22 22"
id="svg2">
diff --git a/gui/kivy/theming/light/mail_icon.png b/electrum/gui/icons/mail_icon.png
similarity index 100%
rename from gui/kivy/theming/light/mail_icon.png
rename to electrum/gui/icons/mail_icon.png
diff --git a/electrum/gui/icons/menu_vertical.png b/electrum/gui/icons/menu_vertical.png
new file mode 100644
index 000000000000..c6329c2294b6
Binary files /dev/null and b/electrum/gui/icons/menu_vertical.png differ
diff --git a/electrum/gui/icons/menu_vertical_white.png b/electrum/gui/icons/menu_vertical_white.png
new file mode 100644
index 000000000000..b818f301eb1f
Binary files /dev/null and b/electrum/gui/icons/menu_vertical_white.png differ
diff --git a/icons/network.png b/electrum/gui/icons/network.png
similarity index 100%
rename from icons/network.png
rename to electrum/gui/icons/network.png
diff --git a/electrum/gui/icons/nostr.png b/electrum/gui/icons/nostr.png
new file mode 100644
index 000000000000..3148343bc2e5
Binary files /dev/null and b/electrum/gui/icons/nostr.png differ
diff --git a/electrum/gui/icons/offline_tx.png b/electrum/gui/icons/offline_tx.png
new file mode 100644
index 000000000000..32fee54dd35c
Binary files /dev/null and b/electrum/gui/icons/offline_tx.png differ
diff --git a/electrum/gui/icons/paste.png b/electrum/gui/icons/paste.png
new file mode 100644
index 000000000000..e70bb37f9759
Binary files /dev/null and b/electrum/gui/icons/paste.png differ
diff --git a/electrum/gui/icons/pen.png b/electrum/gui/icons/pen.png
new file mode 100644
index 000000000000..40e73a305cb8
Binary files /dev/null and b/electrum/gui/icons/pen.png differ
diff --git a/electrum/gui/icons/picture_in_picture.png b/electrum/gui/icons/picture_in_picture.png
new file mode 100644
index 000000000000..b000d3de0d5c
Binary files /dev/null and b/electrum/gui/icons/picture_in_picture.png differ
diff --git a/electrum/gui/icons/preferences.png b/electrum/gui/icons/preferences.png
new file mode 100644
index 000000000000..b10ba6ea0144
Binary files /dev/null and b/electrum/gui/icons/preferences.png differ
diff --git a/electrum/gui/icons/preferences.svg b/electrum/gui/icons/preferences.svg
new file mode 100644
index 000000000000..39f7bd14361b
--- /dev/null
+++ b/electrum/gui/icons/preferences.svg
@@ -0,0 +1,686 @@
+
+
+
diff --git a/electrum/gui/icons/qr_file.png b/electrum/gui/icons/qr_file.png
new file mode 100644
index 000000000000..efacb739ac67
Binary files /dev/null and b/electrum/gui/icons/qr_file.png differ
diff --git a/electrum/gui/icons/qrcode.png b/electrum/gui/icons/qrcode.png
new file mode 100644
index 000000000000..3c1bf8b046aa
Binary files /dev/null and b/electrum/gui/icons/qrcode.png differ
diff --git a/electrum/gui/icons/qrcode_white.png b/electrum/gui/icons/qrcode_white.png
new file mode 100644
index 000000000000..9c675be776e6
Binary files /dev/null and b/electrum/gui/icons/qrcode_white.png differ
diff --git a/electrum/gui/icons/question.png b/electrum/gui/icons/question.png
new file mode 100644
index 000000000000..23b572bed6bb
Binary files /dev/null and b/electrum/gui/icons/question.png differ
diff --git a/electrum/gui/icons/revealer_c.png b/electrum/gui/icons/revealer_c.png
new file mode 100644
index 000000000000..993d8fc90ced
Binary files /dev/null and b/electrum/gui/icons/revealer_c.png differ
diff --git a/electrum/gui/icons/rocket.png b/electrum/gui/icons/rocket.png
new file mode 100644
index 000000000000..a80a1e955235
Binary files /dev/null and b/electrum/gui/icons/rocket.png differ
diff --git a/gui/kivy/theming/light/save.png b/electrum/gui/icons/save.png
similarity index 100%
rename from gui/kivy/theming/light/save.png
rename to electrum/gui/icons/save.png
diff --git a/electrum/gui/icons/script.png b/electrum/gui/icons/script.png
new file mode 100644
index 000000000000..fdb063d54602
Binary files /dev/null and b/electrum/gui/icons/script.png differ
diff --git a/electrum/gui/icons/script_white.png b/electrum/gui/icons/script_white.png
new file mode 100644
index 000000000000..85ad2f1d8509
Binary files /dev/null and b/electrum/gui/icons/script_white.png differ
diff --git a/icons/seal.png b/electrum/gui/icons/seal.png
similarity index 100%
rename from icons/seal.png
rename to electrum/gui/icons/seal.png
diff --git a/icons/seed.png b/electrum/gui/icons/seed.png
similarity index 100%
rename from icons/seed.png
rename to electrum/gui/icons/seed.png
diff --git a/electrum/gui/icons/share.png b/electrum/gui/icons/share.png
new file mode 100644
index 000000000000..d0dc761d4544
Binary files /dev/null and b/electrum/gui/icons/share.png differ
diff --git a/electrum/gui/icons/spinner.gif b/electrum/gui/icons/spinner.gif
new file mode 100644
index 000000000000..0dbdd7819b8f
Binary files /dev/null and b/electrum/gui/icons/spinner.gif differ
diff --git a/electrum/gui/icons/status_connected.png b/electrum/gui/icons/status_connected.png
new file mode 100644
index 000000000000..1fe3dace9d55
Binary files /dev/null and b/electrum/gui/icons/status_connected.png differ
diff --git a/electrum/gui/icons/status_connected.svg b/electrum/gui/icons/status_connected.svg
new file mode 100644
index 000000000000..e0779998c49e
--- /dev/null
+++ b/electrum/gui/icons/status_connected.svg
@@ -0,0 +1,173 @@
+
+
diff --git a/electrum/gui/icons/status_connected_fork.png b/electrum/gui/icons/status_connected_fork.png
new file mode 100644
index 000000000000..a65c2a883ca8
Binary files /dev/null and b/electrum/gui/icons/status_connected_fork.png differ
diff --git a/electrum/gui/icons/status_connected_proxy.png b/electrum/gui/icons/status_connected_proxy.png
new file mode 100644
index 000000000000..ff553d9f17ba
Binary files /dev/null and b/electrum/gui/icons/status_connected_proxy.png differ
diff --git a/electrum/gui/icons/status_connected_proxy.svg b/electrum/gui/icons/status_connected_proxy.svg
new file mode 100644
index 000000000000..5e44b5e514f2
--- /dev/null
+++ b/electrum/gui/icons/status_connected_proxy.svg
@@ -0,0 +1,173 @@
+
+
diff --git a/electrum/gui/icons/status_connected_proxy_fork.png b/electrum/gui/icons/status_connected_proxy_fork.png
new file mode 100644
index 000000000000..f6b4541e1f3b
Binary files /dev/null and b/electrum/gui/icons/status_connected_proxy_fork.png differ
diff --git a/electrum/gui/icons/status_disconnected.png b/electrum/gui/icons/status_disconnected.png
new file mode 100644
index 000000000000..cb5ac1b9fbe3
Binary files /dev/null and b/electrum/gui/icons/status_disconnected.png differ
diff --git a/icons/status_disconnected.svg b/electrum/gui/icons/status_disconnected.svg
similarity index 99%
rename from icons/status_disconnected.svg
rename to electrum/gui/icons/status_disconnected.svg
index 738639ba301f..46d1a1d7f03f 100644
--- a/icons/status_disconnected.svg
+++ b/electrum/gui/icons/status_disconnected.svg
@@ -9,8 +9,9 @@
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
- width="48"
- height="48"
+ width="512"
+ height="512"
+ viewBox="9 9 30 30"
id="svg7854"
sodipodi:version="0.32"
inkscape:version="0.45"
diff --git a/electrum/gui/icons/status_lagging.png b/electrum/gui/icons/status_lagging.png
new file mode 100644
index 000000000000..b558791f5dd1
Binary files /dev/null and b/electrum/gui/icons/status_lagging.png differ
diff --git a/electrum/gui/icons/status_lagging.svg b/electrum/gui/icons/status_lagging.svg
new file mode 100644
index 000000000000..1fd487964702
--- /dev/null
+++ b/electrum/gui/icons/status_lagging.svg
@@ -0,0 +1,173 @@
+
+
diff --git a/electrum/gui/icons/status_lagging_fork.png b/electrum/gui/icons/status_lagging_fork.png
new file mode 100644
index 000000000000..82826721b926
Binary files /dev/null and b/electrum/gui/icons/status_lagging_fork.png differ
diff --git a/electrum/gui/icons/status_waiting.png b/electrum/gui/icons/status_waiting.png
new file mode 100644
index 000000000000..d5513838f842
Binary files /dev/null and b/electrum/gui/icons/status_waiting.png differ
diff --git a/icons/status_waiting.svg b/electrum/gui/icons/status_waiting.svg
similarity index 99%
rename from icons/status_waiting.svg
rename to electrum/gui/icons/status_waiting.svg
index fb51a42b4bbb..069a4d3ddce8 100644
--- a/icons/status_waiting.svg
+++ b/electrum/gui/icons/status_waiting.svg
@@ -12,8 +12,9 @@
inkscape:export-ydpi="90.000000"
inkscape:export-xdpi="90.000000"
inkscape:export-filename="c:\Tango\git\view-refresh.png"
- width="48"
- height="48"
+ width="512"
+ height="512"
+ viewBox="0 0 48 48"
id="svg11300"
sodipodi:version="0.32"
inkscape:version="0.45"
diff --git a/electrum/gui/icons/sweep.png b/electrum/gui/icons/sweep.png
new file mode 100644
index 000000000000..faeb3141788c
Binary files /dev/null and b/electrum/gui/icons/sweep.png differ
diff --git a/icons/tab_addresses.png b/electrum/gui/icons/tab_addresses.png
similarity index 100%
rename from icons/tab_addresses.png
rename to electrum/gui/icons/tab_addresses.png
diff --git a/icons/tab_coins.png b/electrum/gui/icons/tab_coins.png
similarity index 100%
rename from icons/tab_coins.png
rename to electrum/gui/icons/tab_coins.png
diff --git a/icons/tab_console.png b/electrum/gui/icons/tab_console.png
similarity index 100%
rename from icons/tab_console.png
rename to electrum/gui/icons/tab_console.png
diff --git a/icons/tab_contacts.png b/electrum/gui/icons/tab_contacts.png
similarity index 100%
rename from icons/tab_contacts.png
rename to electrum/gui/icons/tab_contacts.png
diff --git a/icons/tab_history.png b/electrum/gui/icons/tab_history.png
similarity index 100%
rename from icons/tab_history.png
rename to electrum/gui/icons/tab_history.png
diff --git a/icons/tab_receive.png b/electrum/gui/icons/tab_receive.png
similarity index 100%
rename from icons/tab_receive.png
rename to electrum/gui/icons/tab_receive.png
diff --git a/icons/tab_send.png b/electrum/gui/icons/tab_send.png
similarity index 100%
rename from icons/tab_send.png
rename to electrum/gui/icons/tab_send.png
diff --git a/icons/tor_logo.png b/electrum/gui/icons/tor_logo.png
similarity index 100%
rename from icons/tor_logo.png
rename to electrum/gui/icons/tor_logo.png
diff --git a/electrum/gui/icons/unconfirmed.png b/electrum/gui/icons/unconfirmed.png
new file mode 100644
index 000000000000..6ebfe290ea58
Binary files /dev/null and b/electrum/gui/icons/unconfirmed.png differ
diff --git a/electrum/gui/icons/unlock.png b/electrum/gui/icons/unlock.png
new file mode 100644
index 000000000000..869e4de326d3
Binary files /dev/null and b/electrum/gui/icons/unlock.png differ
diff --git a/icons/unlock.svg b/electrum/gui/icons/unlock.svg
similarity index 99%
rename from icons/unlock.svg
rename to electrum/gui/icons/unlock.svg
index 9e1c09fb07b1..b22e40207014 100644
--- a/icons/unlock.svg
+++ b/electrum/gui/icons/unlock.svg
@@ -11,8 +11,9 @@
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.0"
- width="22"
- height="22"
+ width="512"
+ height="512"
+ viewBox="0 0 22 22"
id="svg2"
inkscape:version="0.47 r22583"
sodipodi:docname="unlock.svg">
diff --git a/electrum/gui/icons/unpaid.png b/electrum/gui/icons/unpaid.png
new file mode 100644
index 000000000000..579ec4eb5338
Binary files /dev/null and b/electrum/gui/icons/unpaid.png differ
diff --git a/electrum/gui/icons/update.png b/electrum/gui/icons/update.png
new file mode 100644
index 000000000000..e774ee10e4b3
Binary files /dev/null and b/electrum/gui/icons/update.png differ
diff --git a/gui/kivy/theming/light/wallet.png b/electrum/gui/icons/wallet.png
similarity index 100%
rename from gui/kivy/theming/light/wallet.png
rename to electrum/gui/icons/wallet.png
diff --git a/icons/warning.png b/electrum/gui/icons/warning.png
similarity index 100%
rename from icons/warning.png
rename to electrum/gui/icons/warning.png
diff --git a/icons/zoom.png b/electrum/gui/icons/zoom.png
similarity index 100%
rename from icons/zoom.png
rename to electrum/gui/icons/zoom.png
diff --git a/electrum/gui/messages.py b/electrum/gui/messages.py
new file mode 100644
index 000000000000..c823eabd8d1b
--- /dev/null
+++ b/electrum/gui/messages.py
@@ -0,0 +1,165 @@
+from electrum.i18n import _
+from electrum.submarine_swaps import MIN_FINAL_CLTV_DELTA_FOR_CLIENT
+
+
+def to_rtf(msg):
+ return '\n'.join(['
' + x + '
' for x in msg.split('\n\n')])
+
+
+MSG_COOPERATIVE_CLOSE = _(
+"""Your node will negotiate the transaction fee with the remote node. This method of closing the channel usually results in the lowest fees."""
+)
+
+MSG_REQUEST_FORCE_CLOSE = _(
+"""If you request a force-close, your node will pretend that it has lost its data and ask the remote node to broadcast their latest state. Doing so from time to time helps make sure that nodes are honest, because your node can punish them if they broadcast a revoked state."""
+)
+
+MSG_CREATED_NON_RECOVERABLE_CHANNEL = _(
+"""The channel you created is not recoverable from seed.
+To prevent fund losses, please save this backup on another device.
+It may be imported in another Electrum wallet with the same seed."""
+)
+
+MSG_LIGHTNING_WARNING = _(
+"""Electrum uses static channel backups. If you lose your wallet file, you will need to request your channel to be force-closed by the remote peer in order to recover your funds. This assumes that the remote peer is reachable, and has not lost its own data."""
+)
+
+MSG_THIRD_PARTY_PLUGIN_WARNING = ' '.join([
+ '' + _('Warning: Third-party plugins have access to your wallet!') + '',
+ '
',
+ _('Installing this plugin will grant third-party software access to your wallet. You must trust the plugin not to be malicious.'),
+ _('You should at minimum check who the author of the plugin is, and be careful of imposters.'),
+ '
',
+ _('Third-party plugins are not endorsed by Electrum.'),
+ _('Electrum will not be responsible in case of theft, loss of funds or privacy that might result from third-party plugins.'),
+ '
',
+ qsTr('Please save these %1 words on paper (order is important).').arg(numwords),
+ qsTr('This seed will allow you to recover your wallet in case of computer failure.'),
+ '
' +
+ _("Please report this issue manually") +
+ f' on GitHub.')
+ else:
+ text = response.text
+ if response.url:
+ text += f" You can track further progress on GitHub."
+ self.sendingBugreportSuccess.emit(text)
+
+ self.sendingBugreport.emit()
+ threading.Thread(target=report_task, daemon=True).start()
+
+ def _get_traceback_str_to_display(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_to_display()
+ return html.escape(traceback_str).replace(''', ''')
+
+ def get_user_description(self):
+ return self._crash_user_text
+
+ def get_wallet_type(self):
+ wallet_types = Exception_Hook._INSTANCE.wallet_types_seen
+ return ",".join(wallet_types)
+
+ @pyqtSlot()
+ def haptic(self):
+ if not self.isAndroid():
+ return
+ jview.performHapticFeedback(jHfc.VIRTUAL_KEY)
+
+ @pyqtProperty(bool, notify=secureWindowChanged)
+ def secureWindow(self):
+ return self._secureWindow
+
+ @secureWindow.setter
+ def secureWindow(self, secure):
+ if not self.isAndroid():
+ return
+ if self.config.GUI_QML_ALWAYS_ALLOW_SCREENSHOTS:
+ return
+ if self._secureWindow != secure:
+ jpythonActivity.setSecureWindow(secure)
+ self._secureWindow = secure
+ self.secureWindowChanged.emit()
+
+ @pyqtSlot(result=bool)
+ def enforcesEdgeToEdge(self) -> bool:
+ if not self.isAndroid():
+ return False
+ return bool(systemSdkVersion >= 35)
+
+ @profiler(min_threshold=0.02)
+ def _getSystemBarHeight(self, bar_type: str) -> int:
+ if not self.enforcesEdgeToEdge():
+ return 0
+ assert systemSdkVersion >= 30, \
+ f"Android WindowInsets unavailable on {systemSdkVersion=}"
+ try:
+ root_insets = jview.getRootWindowInsets()
+ window_insets_type = autoclass('android.view.WindowInsets$Type')
+
+ if bar_type == 'status':
+ ins = root_insets.getInsets(window_insets_type.statusBars())
+ elif bar_type == 'navigation':
+ ins = root_insets.getInsets(window_insets_type.navigationBars())
+ else:
+ raise ValueError(f"Invalid bar_type: {bar_type}")
+
+ # Get the display metrics to convert pixels to dp
+ display_metrics = jpythonActivity.getResources().getDisplayMetrics()
+ density = display_metrics.density
+
+ height = int(max(ins.bottom, ins.right, ins.left, ins.top, 0))
+ if not height > 0:
+ return 0
+
+ # Convert from pixels to dp for QML
+ height_dp = int(height / density)
+
+ self.logger.debug(f"_getSystemBarHeight: {height=}, {height_dp=}, {bar_type=}")
+ return max(0, height_dp)
+ except Exception as e:
+ self.logger.debug(f"{bar_type} fallback due to: {e!r}")
+ return 0
+
+ @pyqtSlot(result=int)
+ def getStatusBarHeight(self) -> int:
+ return self._getSystemBarHeight('status')
+
+ @pyqtSlot(result=int)
+ def getNavigationBarHeight(self) -> int:
+ return self._getSystemBarHeight('navigation')
+
+
+class ElectrumQmlApplication(QGuiApplication):
+
+ _valid = True
+
+ def __init__(self, args, *, config: 'SimpleConfig', daemon: 'Daemon', plugins: 'Plugins'):
+ super().__init__(args)
+
+ self.logger = get_logger(__name__)
+
+ # TODO QT6 order of declaration is important now?
+ qmlRegisterType(QEAmount, 'org.electrum', 1, 0, 'Amount')
+ qmlRegisterType(QEBytes, 'org.electrum', 1, 0, 'Bytes')
+ qmlRegisterType(QENewWalletWizard, 'org.electrum', 1, 0, 'QNewWalletWizard')
+ qmlRegisterType(QETermsOfUseWizard, 'org.electrum', 1, 0, 'QTermsOfUseWizard')
+ qmlRegisterType(QEServerConnectWizard, 'org.electrum', 1, 0, 'QServerConnectWizard')
+ qmlRegisterType(QEFilterProxyModel, 'org.electrum', 1, 0, 'FilterProxyModel')
+ qmlRegisterType(QSortFilterProxyModel, 'org.electrum', 1, 0, 'QSortFilterProxyModel')
+
+ qmlRegisterType(QEWallet, 'org.electrum', 1, 0, 'Wallet')
+ qmlRegisterType(QEBitcoin, 'org.electrum', 1, 0, 'Bitcoin')
+ qmlRegisterType(QEQRParser, 'org.electrum', 1, 0, 'QRParser')
+ qmlRegisterType(QEQRScanner, 'org.electrum', 1, 0, 'QRScanner')
+ qmlRegisterType(QEFX, 'org.electrum', 1, 0, 'FX')
+ qmlRegisterType(QETxFinalizer, 'org.electrum', 1, 0, 'TxFinalizer')
+ qmlRegisterType(QEPIResolver, 'org.electrum', 1, 0, 'PIResolver')
+ qmlRegisterType(QEInvoice, 'org.electrum', 1, 0, 'Invoice')
+ qmlRegisterType(QEInvoiceParser, 'org.electrum', 1, 0, 'InvoiceParser')
+ qmlRegisterType(QEAddressDetails, 'org.electrum', 1, 0, 'AddressDetails')
+ qmlRegisterType(QETxDetails, 'org.electrum', 1, 0, 'TxDetails')
+ qmlRegisterType(QEChannelOpener, 'org.electrum', 1, 0, 'ChannelOpener')
+ qmlRegisterType(QELnPaymentDetails, 'org.electrum', 1, 0, 'LnPaymentDetails')
+ qmlRegisterType(QEChannelDetails, 'org.electrum', 1, 0, 'ChannelDetails')
+ qmlRegisterType(QESwapHelper, 'org.electrum', 1, 0, 'SwapHelper')
+ qmlRegisterType(QERequestDetails, 'org.electrum', 1, 0, 'RequestDetails')
+ qmlRegisterType(QETxRbfFeeBumper, 'org.electrum', 1, 0, 'TxRbfFeeBumper')
+ qmlRegisterType(QETxCpfpFeeBumper, 'org.electrum', 1, 0, 'TxCpfpFeeBumper')
+ qmlRegisterType(QETxCanceller, 'org.electrum', 1, 0, 'TxCanceller')
+ qmlRegisterType(QETxSweepFinalizer, 'org.electrum', 1, 0, 'SweepFinalizer')
+ qmlRegisterType(QEBip39RecoveryListModel, 'org.electrum', 1, 0, 'Bip39RecoveryListModel')
+ qmlRegisterType(FeeSlider, 'org.electrum', 1, 0, 'FeeSlider')
+ # TODO QT6: these were declared as uncreatable, but that doesn't seem to work for pyqt6
+ # qmlRegisterUncreatableType(QEAmount, 'org.electrum', 1, 0, 'Amount', 'Amount can only be used as property')
+ # qmlRegisterUncreatableType(QENewWalletWizard, 'org.electrum', 1, 0, 'QNewWalletWizard', 'QNewWalletWizard can only be used as property')
+ # qmlRegisterUncreatableType(QEServerConnectWizard, 'org.electrum', 1, 0, 'QServerConnectWizard', 'QServerConnectWizard can only be used as property')
+ # qmlRegisterUncreatableType(QEFilterProxyModel, 'org.electrum', 1, 0, 'FilterProxyModel', 'FilterProxyModel can only be used as property')
+ # qmlRegisterUncreatableType(QSortFilterProxyModel, 'org.electrum', 1, 0, 'QSortFilterProxyModel', 'QSortFilterProxyModel can only be used as property')
+
+ self.engine = QQmlApplicationEngine(parent=self)
+
+ screensize = self.primaryScreen().size()
+
+ qr_size = min(screensize.width(), screensize.height()) * 7/8
+ self.qr_ip = QEQRImageProvider(qr_size)
+ self.engine.addImageProvider('qrgen', self.qr_ip)
+ self.qr_ip_h = QEQRImageProviderHelper(qr_size)
+
+ # add a monospace font as we can't rely on device having one
+ self.fixedFont = 'PT Mono'
+ not_loaded = get_font_id('PTMono-Regular.ttf') < 0
+ not_loaded = get_font_id('PTMono-Bold.ttf') < 0 and not_loaded
+ if not_loaded:
+ self.logger.warning('Could not load font PT Mono')
+ self.fixedFont = 'Monospace' # hope for the best
+
+ self.context = self.engine.rootContext()
+ self.plugins = plugins
+ self.config = QEConfig(config)
+ self.network = QENetwork(daemon.network)
+ self.daemon = QEDaemon(daemon, self.plugins)
+ self.appController = QEAppController(self, self.plugins)
+ self.maxAmount = QEAmount(is_max=True)
+ self.biometrics = QEBiometrics(config=config, parent=self)
+ self.context.setContextProperty('AppController', self.appController)
+ self.context.setContextProperty('Config', self.config)
+ self.context.setContextProperty('Network', self.network)
+ self.context.setContextProperty('Daemon', self.daemon)
+ self.context.setContextProperty('FixedFont', self.fixedFont)
+ self.context.setContextProperty('MAX', self.maxAmount)
+ self.context.setContextProperty('QRIP', self.qr_ip_h)
+ self.context.setContextProperty('Biometrics', self.biometrics)
+ self.context.setContextProperty('BUILD', {
+ 'electrum_version': version.ELECTRUM_VERSION,
+ 'protocol_version': f"[{version.PROTOCOL_VERSION_MIN}, {version.PROTOCOL_VERSION_MAX}]",
+ 'qt_version': QT_VERSION_STR,
+ 'pyqt_version': PYQT_VERSION_STR
+ })
+ self.context.setContextProperty('UI_UNIT_NAME', {
+ "FEERATE_SAT_PER_VBYTE": electrum.util.UI_UNIT_NAME_FEERATE_SAT_PER_VBYTE,
+ "FEERATE_SAT_PER_VB": electrum.util.UI_UNIT_NAME_FEERATE_SAT_PER_VB,
+ "FIXED_SAT": electrum.util.UI_UNIT_NAME_FIXED_SAT,
+ "TXSIZE_VBYTES": electrum.util.UI_UNIT_NAME_TXSIZE_VBYTES,
+ "MEMPOOL_MB": electrum.util.UI_UNIT_NAME_MEMPOOL_MB,
+ })
+
+ self.plugins.load_plugin_by_name('trustedcoin')
+
+ qInstallMessageHandler(self.message_handler)
+
+ # get notified whether root QML document loads or not
+ self.engine.objectCreated.connect(self.objectCreated)
+
+ # slot is called after loading root QML. If object is None, it has failed.
+ @pyqtSlot('QObject*', 'QUrl')
+ def objectCreated(self, object, url):
+ self.engine.objectCreated.disconnect(self.objectCreated)
+ if object is None:
+ self._valid = False
+ else:
+ self.appController.startup_finished()
+
+ def message_handler(self, line, funct, file):
+ # filter out common harmless messages
+ if re.search('file:///.*TypeError: Cannot read property.*null$', file):
+ return
+ self.logger.warning(file)
+
+
+class Exception_Hook(QObject, Logger):
+ _report_exception = pyqtSignal(object, object, object)
+
+ _INSTANCE = None # type: Optional[Exception_Hook] # singleton
+
+ def __init__(self, *, slot):
+ QObject.__init__(self)
+ Logger.__init__(self)
+ assert self._INSTANCE is None, "Exception_Hook is supposed to be a singleton"
+ self.wallet_types_seen = set() # type: Set[str]
+ self.exception_ids_seen = set() # type: Set[bytes]
+
+ sys.excepthook = self.handler
+ threading.excepthook = self.handler
+
+ if slot:
+ self._report_exception.connect(slot)
+ EarlyExceptionsQueue.set_hook_as_ready()
+
+ @classmethod
+ def maybe_setup(cls, *, wallet: 'Abstract_Wallet' = None, slot=None) -> None:
+ if not cls._INSTANCE:
+ cls._INSTANCE = Exception_Hook(slot=slot)
+ if wallet:
+ 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)
+ groupid_hash = BaseCrashReporter.get_traceback_groupid_hash(*exc_info)
+ if groupid_hash in self.exception_ids_seen:
+ return # to avoid annoying the user, only show crash reporter once per exception groupid
+ self.exception_ids_seen.add(groupid_hash)
+ self._report_exception.emit(*exc_info)
diff --git a/electrum/gui/qml/qebiometrics.py b/electrum/gui/qml/qebiometrics.py
new file mode 100644
index 000000000000..b2d896bd17b5
--- /dev/null
+++ b/electrum/gui/qml/qebiometrics.py
@@ -0,0 +1,204 @@
+import os
+import secrets
+from enum import Enum
+from typing import Optional, TYPE_CHECKING
+
+from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty
+
+from electrum.i18n import _
+from electrum.logging import get_logger
+from electrum.base_crash_reporter import send_exception_to_crash_reporter
+from electrum.crypto import aes_encrypt_with_iv, aes_decrypt_with_iv
+
+from .auth import auth_protect, AuthMixin
+
+if TYPE_CHECKING:
+ from electrum.simple_config import SimpleConfig
+
+
+_logger = get_logger(__name__)
+
+
+jBiometricHelper = None
+jBiometricActivity = None
+jPythonActivity = None
+jIntent = None
+jString = None
+
+if 'ANDROID_DATA' in os.environ:
+ from jnius import autoclass, JavaException
+ from android import activity
+ try:
+ jPythonActivity = autoclass('org.kivy.android.PythonActivity').mActivity
+ jIntent = autoclass('android.content.Intent')
+ jString = autoclass('java.lang.String')
+ jBiometricActivity = autoclass('org.electrum.biometry.BiometricActivity')
+ jBiometricHelper = autoclass('org.electrum.biometry.BiometricHelper')
+ except JavaException as e:
+ _logger.error(f"Could not load Biometric java classes (maybe due to old api version): {e}")
+
+
+class BiometricAction(str, Enum):
+ ENCRYPT = "ENCRYPT"
+ DECRYPT = "DECRYPT"
+
+
+class QEBiometrics(AuthMixin, QObject):
+ REQUEST_CODE_BIOMETRIC_ACTIVITY = 24553 # random 16 bit int
+ RESULT_CODE_SETUP_FAILED = 101 # codes duplicated from BiometricActivity.java
+ RESULT_CODE_POPUP_CANCELLED = 102
+
+ enablingFailed = pyqtSignal(str, arguments=['error'])
+ unlockSuccess = pyqtSignal(str, arguments=['password'])
+ unlockError = pyqtSignal(str, arguments=['error'])
+
+ def __init__(self, *, config: 'SimpleConfig', parent=None):
+ super().__init__(parent)
+ self.config = config
+ self._current_action: Optional[BiometricAction] = None
+
+ @pyqtProperty(bool, constant=True)
+ def isAvailable(self) -> bool:
+ if 'ANDROID_DATA' not in os.environ or jBiometricHelper is None:
+ return False
+ try:
+ return jBiometricHelper.isAvailable(jPythonActivity)
+ except Exception as e:
+ send_exception_to_crash_reporter(e)
+ return False
+
+ isEnabledChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=isEnabledChanged)
+ def isEnabled(self) -> bool:
+ return self.config.WALLET_ANDROID_USE_BIOMETRIC_AUTHENTICATION
+
+ @pyqtSlot(str)
+ def enable(self, unified_wallet_password: str):
+ """
+ We encrypt (`wrap`) the wallet password with a random key 'wrap_key' and encrypt the random key
+ with the AndroidKeyStore.
+ Both the encrypted wrap_key and the encrypted wallet password are stored in the config.
+ The encryption key for the wrap_key is stored in the AndroidKeyStore.
+ This way the wallet password doesn't have to leave the process.
+ """
+ wrap_key, iv = secrets.token_bytes(32), secrets.token_bytes(16)
+ wrapped_wallet_password = aes_encrypt_with_iv(
+ key=wrap_key,
+ iv=iv,
+ data=unified_wallet_password.encode('utf-8'),
+ )
+ encrypted_password_bundle = f"{iv.hex()}:{wrapped_wallet_password.hex()}"
+ self.config.WALLET_ANDROID_BIOMETRIC_AUTH_WRAPPED_WALLET_PASSWORD = encrypted_password_bundle
+ self._start_activity(BiometricAction.ENCRYPT, data=wrap_key.hex())
+
+ @pyqtSlot()
+ def disable(self):
+ self.config.WALLET_ANDROID_USE_BIOMETRIC_AUTHENTICATION = False
+ self.config.WALLET_ANDROID_BIOMETRIC_AUTH_WRAPPED_WALLET_PASSWORD = ''
+ self.config.WALLET_ANDROID_BIOMETRIC_AUTH_ENCRYPTED_WRAP_KEY = ''
+ self.isEnabledChanged.emit()
+ _logger.info("Android biometric authentication disabled")
+
+ @pyqtSlot()
+ @auth_protect(method='wallet_password_only', reject='_disable_protected_failed')
+ def disableProtected(self):
+ """
+ Exists to ensure the user knows the wallet password when manually disabling
+ biometric authentication. If they don't remember the password they can still do a seed
+ backup or transactions if biometrics stay enabled. However, note it is still possible for
+ biometrics to get disabled automatically on invalidation or error, so this cannot
+ fully protect the user from forgetting their wallet password either.
+ """
+ self.disable()
+
+ def _disable_protected_failed(self):
+ self.isEnabledChanged.emit()
+
+ @pyqtSlot()
+ @pyqtSlot(str)
+ def unlock(self, auth_message: str = None):
+ """
+ Called when the user needs to authenticate.
+ Makes the AndroidKeyStore decrypt our encrypted wrap key, we then use the decrypted wrap key
+ to decrypt the encrypted wallet password.
+ auth_message is shown in the system auth popup and defaults to 'Confirm your identity'.
+ """
+ encrypted_wrap_key = self.config.WALLET_ANDROID_BIOMETRIC_AUTH_ENCRYPTED_WRAP_KEY
+ assert encrypted_wrap_key, "shouldn't unlock if biometric auth is disabled"
+ self._start_activity(BiometricAction.DECRYPT, data=encrypted_wrap_key, auth_message=auth_message)
+
+ def _start_activity(self, action: BiometricAction, data: str, auth_message: str = None):
+ self._current_action = action
+
+ _logger.debug(f"_start_activity: {action.value}, {len(data)=}")
+ intent = jIntent(jPythonActivity, jBiometricActivity)
+ intent.putExtra(jString("action"), jString(action.value))
+ intent.putExtra(jString("auth_message"), jString(auth_message or _("Confirm your identity")))
+ if action == BiometricAction.ENCRYPT:
+ intent.putExtra(jString("data"), jString(data)) # wrap_key
+ elif action == BiometricAction.DECRYPT:
+ assert ':' in data, f"malformed encrypted_bundle: {data=}"
+ iv, encrypted_wrap_key = data.split(':')
+ intent.putExtra(jString("iv"), jString(iv))
+ intent.putExtra(jString("data"), jString(encrypted_wrap_key))
+ else:
+ raise ValueError(f"unsupported {action=}")
+
+ activity.bind(on_activity_result=self._on_activity_result)
+ jPythonActivity.startActivityForResult(intent, self.REQUEST_CODE_BIOMETRIC_ACTIVITY)
+
+ def _on_activity_result(self, requestCode: int, resultCode: int, intent):
+ if requestCode != self.REQUEST_CODE_BIOMETRIC_ACTIVITY:
+ return
+
+ action = self._current_action
+ self._current_action = None
+
+ try:
+ activity.unbind(on_activity_result=self._on_activity_result)
+ if resultCode == -1: # RESULT_OK
+ data = intent.getStringExtra(jString("data"))
+ if action == BiometricAction.ENCRYPT:
+ iv = intent.getStringExtra(jString("iv"))
+ encrypted_bundle = f"{iv}:{data}"
+ self._on_wrap_key_encrypted(encrypted_bundle=encrypted_bundle)
+ else:
+ self._on_wrap_key_decrypted(wrap_key=data)
+ return
+ except Exception as e: # prevent exc from getting lost
+ send_exception_to_crash_reporter(e)
+
+ # on qml side we act on specific errors, so these error strings shouldn't be changed
+ if resultCode == self.RESULT_CODE_SETUP_FAILED and action == BiometricAction.DECRYPT:
+ # setup failed, we need to delete the biometry data, it cannot be decrypted anymore
+ _logger.debug(f"biometric decryption failed, probably invalidated key")
+ error = 'INVALIDATED'
+ self.disable() # reset
+ elif resultCode == self.RESULT_CODE_POPUP_CANCELLED: # user clicked cancel on auth popup
+ _logger.debug(f"biometric auth cancelled by user")
+ error = 'CANCELLED'
+ else: # some other error
+ _logger.error(f"biometric auth failed: {action=}, {resultCode=}")
+ error = f"{resultCode=}"
+
+ if action == BiometricAction.DECRYPT:
+ self.unlockError.emit(error)
+ else:
+ self.disable() # reset
+ self.enablingFailed.emit(error)
+
+ def _on_wrap_key_decrypted(self, *, wrap_key: str):
+ encrypted_password_bundle = self.config.WALLET_ANDROID_BIOMETRIC_AUTH_WRAPPED_WALLET_PASSWORD
+ assert encrypted_password_bundle and ':' in encrypted_password_bundle
+ iv, encrypted_password = encrypted_password_bundle.split(':')
+ decrypted_password = aes_decrypt_with_iv(
+ key=bytes.fromhex(wrap_key),
+ iv=bytes.fromhex(iv),
+ data=bytes.fromhex(encrypted_password),
+ )
+ self.unlockSuccess.emit(decrypted_password.decode('utf-8'))
+
+ def _on_wrap_key_encrypted(self, *, encrypted_bundle: str):
+ self.config.WALLET_ANDROID_BIOMETRIC_AUTH_ENCRYPTED_WRAP_KEY = encrypted_bundle
+ self.config.WALLET_ANDROID_USE_BIOMETRIC_AUTHENTICATION = True
+ self.isEnabledChanged.emit()
diff --git a/electrum/gui/qml/qebip39recovery.py b/electrum/gui/qml/qebip39recovery.py
new file mode 100644
index 000000000000..fd2346f573c5
--- /dev/null
+++ b/electrum/gui/qml/qebip39recovery.py
@@ -0,0 +1,123 @@
+import asyncio
+import concurrent
+from enum import IntEnum
+
+from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, pyqtEnum
+from PyQt6.QtCore import Qt, QAbstractListModel, QModelIndex
+
+from electrum import Network, keystore
+from electrum.bip32 import BIP32Node
+from electrum.bip39_recovery import account_discovery
+from electrum.logging import get_logger
+from electrum.util import get_asyncio_loop
+
+from electrum.gui.common_qt.util import TaskThread
+
+
+class QEBip39RecoveryListModel(QAbstractListModel):
+ _logger = get_logger(__name__)
+
+ @pyqtEnum
+ class State(IntEnum):
+ Idle = -1
+ Scanning = 0
+ Success = 1
+ Failed = 2
+ Cancelled = 3
+
+ recoveryFailed = pyqtSignal()
+ stateChanged = pyqtSignal()
+
+ # define listmodel rolemap
+ _ROLE_NAMES=('description', 'derivation_path', 'script_type')
+ _ROLE_KEYS = range(Qt.ItemDataRole.UserRole, Qt.ItemDataRole.UserRole + len(_ROLE_NAMES))
+ _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES]))
+
+ def __init__(self, config, parent=None):
+ super().__init__(parent)
+ self._accounts = []
+ self._thread = None
+ self._root_seed = None
+ self._state = QEBip39RecoveryListModel.State.Idle
+
+ def rowCount(self, index):
+ return len(self._accounts)
+
+ def roleNames(self):
+ return self._ROLE_MAP
+
+ def data(self, index, role):
+ account = self._accounts[index.row()]
+ role_index = role - Qt.ItemDataRole.UserRole
+ value = account[self._ROLE_NAMES[role_index]]
+ if isinstance(value, (bool, list, int, str)) or value is None:
+ return value
+ return str(value)
+
+ def clear(self):
+ self.beginResetModel()
+ self._accounts = []
+ self.endResetModel()
+
+ @pyqtProperty(int, notify=stateChanged)
+ def state(self):
+ return self._state
+
+ @state.setter
+ def state(self, state: State):
+ if state != self._state:
+ self._state = state
+ self.stateChanged.emit()
+
+ @pyqtSlot(str, str)
+ @pyqtSlot(str, str, str)
+ def startScan(self, wallet_type: str, seed: str, seed_extra_words: str = None):
+ if not seed or not wallet_type:
+ return
+
+ assert wallet_type == 'standard'
+
+ self._root_seed = keystore.bip39_to_seed(seed, passphrase=seed_extra_words)
+
+ self.clear()
+
+ self._thread = TaskThread(self)
+ network = Network.get_instance()
+ coro = account_discovery(network, self.get_account_xpub)
+ self.state = QEBip39RecoveryListModel.State.Scanning
+ fut = asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop())
+ self._thread.add(
+ fut.result,
+ on_success=self.on_recovery_success,
+ on_error=self.on_recovery_error,
+ cancel=fut.cancel,
+ )
+
+ def addAccount(self, account):
+ self._logger.debug(f'addAccount {account!r}')
+ self.beginInsertRows(QModelIndex(), len(self._accounts), len(self._accounts))
+ self._accounts.append(account)
+ self.endInsertRows()
+
+ def on_recovery_success(self, accounts):
+ self.state = QEBip39RecoveryListModel.State.Success
+
+ for account in accounts:
+ self.addAccount(account)
+
+ self._thread.stop()
+
+ def on_recovery_error(self, exc_info):
+ e = exc_info[1]
+ if isinstance(e, concurrent.futures.CancelledError):
+ self.state = QEBip39RecoveryListModel.State.Cancelled
+ return
+ self._logger.error(f'recovery error', exc_info=exc_info)
+ self.state = QEBip39RecoveryListModel.State.Failed
+ self._thread.stop()
+
+ def get_account_xpub(self, account_path):
+ root_node = BIP32Node.from_rootseed(self._root_seed, xtype='standard')
+ account_node = root_node.subkey_at_private_derivation(account_path)
+ account_xpub = account_node.to_xpub()
+ return account_xpub
diff --git a/electrum/gui/qml/qebitcoin.py b/electrum/gui/qml/qebitcoin.py
new file mode 100644
index 000000000000..2f1f0df409fb
--- /dev/null
+++ b/electrum/gui/qml/qebitcoin.py
@@ -0,0 +1,127 @@
+import asyncio
+
+from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject
+
+from electrum import mnemonic
+from electrum import keystore
+from electrum.i18n import _
+from electrum.bip32 import is_bip32_derivation, xpub_type
+from electrum.logging import get_logger
+from electrum.util import get_asyncio_loop
+from electrum.transaction import tx_from_any
+from electrum.mnemonic import Mnemonic
+from electrum.old_mnemonic import wordlist as old_wordlist
+from electrum.bitcoin import is_address
+
+
+class QEBitcoin(QObject):
+ _logger = get_logger(__name__)
+
+ generatedSeedChanged = pyqtSignal()
+ seedTypeChanged = pyqtSignal()
+ validationMessageChanged = pyqtSignal()
+
+ def __init__(self, config, parent=None):
+ super().__init__(parent)
+ self.config = config
+ self._seed_type = ''
+ self._generated_seed = ''
+ self._validationMessage = ''
+ self._words = None
+
+ @pyqtProperty(str, notify=generatedSeedChanged)
+ def generatedSeed(self):
+ return self._generated_seed
+
+ @pyqtProperty(str, notify=seedTypeChanged)
+ def seedType(self):
+ return self._seed_type
+
+ @pyqtProperty(str, notify=validationMessageChanged)
+ def validationMessage(self):
+ return self._validationMessage
+
+ @validationMessage.setter
+ def validationMessage(self, msg):
+ if self._validationMessage != msg:
+ self._validationMessage = msg
+ self.validationMessageChanged.emit()
+
+ @pyqtSlot()
+ @pyqtSlot(str)
+ @pyqtSlot(str, str)
+ def generateSeed(self, seed_type='segwit', language='en'):
+ self._logger.debug('generating seed of type ' + str(seed_type))
+
+ async def co_gen_seed(seed_type, language):
+ self._generated_seed = mnemonic.Mnemonic(language).make_seed(seed_type=seed_type)
+ self._logger.debug('seed generated')
+ self.generatedSeedChanged.emit()
+
+ asyncio.run_coroutine_threadsafe(co_gen_seed(seed_type, language), get_asyncio_loop())
+
+ @pyqtSlot(str, str, result=bool)
+ def verifyMasterKey(self, key, wallet_type='standard'):
+ self.validationMessage = ''
+ if not keystore.is_master_key(key):
+ self.validationMessage = _('Not a master key')
+ return False
+
+ k = keystore.from_master_key(key)
+ if wallet_type == 'standard':
+ if isinstance(k, keystore.Xpub): # has bip32 xpub
+ t1 = xpub_type(k.xpub)
+ if t1 not in ['standard', 'p2wpkh', 'p2wpkh-p2sh']: # disallow Ypub/Zpub
+ self.validationMessage = '%s: %s' % (_('Wrong key type'), t1)
+ return False
+ elif isinstance(k, keystore.Old_KeyStore):
+ pass
+ else:
+ self._logger.error(f"unexpected keystore type: {type(keystore)}")
+ return False
+ elif wallet_type == 'multisig':
+ if not isinstance(k, keystore.Xpub): # old mpk?
+ self.validationMessage = '%s: %s' % (_('Wrong key type'), "not bip32")
+ return False
+ t1 = xpub_type(k.xpub)
+ if t1 not in ['standard', 'p2wsh', 'p2wsh-p2sh']: # disallow ypub/zpub
+ self.validationMessage = '%s: %s' % (_('Wrong key type'), t1)
+ return False
+ else:
+ self.validationMessage = '%s: %s' % (_('Unsupported wallet type'), wallet_type)
+ self._logger.error(f'Unsupported wallet type: {wallet_type}')
+ return False
+ # looks okay
+ return True
+
+ @pyqtSlot(str, result=bool)
+ def verifyDerivationPath(self, path):
+ return is_bip32_derivation(path)
+
+ @pyqtSlot(str, result=bool)
+ def isRawTx(self, rawtx):
+ try:
+ tx_from_any(rawtx)
+ return True
+ except Exception:
+ return False
+
+ @pyqtSlot(str, result=bool)
+ def isAddress(self, addr: str):
+ return is_address(addr)
+
+ @pyqtSlot(str, result=bool)
+ def isAddressList(self, csv: str):
+ return keystore.is_address_list(csv)
+
+ @pyqtSlot(str, result=bool)
+ def isPrivateKeyList(self, csv: str):
+ return keystore.is_private_key_list(csv)
+
+ @pyqtSlot(str, result='QVariantList')
+ def mnemonicsFor(self, fragment):
+ if not fragment:
+ return []
+ if not self._words:
+ self._words = set(Mnemonic('en').wordlist).union(set(old_wordlist))
+ return sorted(filter(lambda x: x.startswith(fragment), self._words))
diff --git a/electrum/gui/qml/qechanneldetails.py b/electrum/gui/qml/qechanneldetails.py
new file mode 100644
index 000000000000..be9c2d525604
--- /dev/null
+++ b/electrum/gui/qml/qechanneldetails.py
@@ -0,0 +1,331 @@
+import threading
+from enum import IntEnum
+from typing import Optional, TYPE_CHECKING
+
+from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, pyqtEnum, QVariant
+
+from electrum.i18n import _
+from electrum.gui import messages
+from electrum.logging import get_logger
+from electrum.lnutil import LOCAL, REMOTE
+from electrum.lnchannel import ChanCloseOption, ChannelState, AbstractChannel, Channel, ChannelBackup
+from electrum.util import format_short_id, event_listener
+
+from electrum.gui.common_qt.util import QtEventListener
+
+from .auth import AuthMixin, auth_protect
+from .qewallet import QEWallet
+from .qetypes import QEAmount
+
+if TYPE_CHECKING:
+ from electrum.wallet import Abstract_Wallet
+
+
+class QEChannelDetails(AuthMixin, QObject, QtEventListener):
+ _logger = get_logger(__name__)
+
+ @pyqtEnum
+ class State(IntEnum): # subset, only ones we currently need in UI
+ Closed = ChannelState.CLOSED
+ Redeemed = ChannelState.REDEEMED
+
+ channelChanged = pyqtSignal()
+ channelCloseSuccess = pyqtSignal()
+ channelCloseFailed = pyqtSignal([str], arguments=['message'])
+ isClosingChanged = pyqtSignal()
+ trampolineFrozenInGossipMode = pyqtSignal()
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ self._wallet = None # type: Optional[QEWallet]
+ self._channelid = None # type: Optional[str]
+ self._channel = None # type: Optional[AbstractChannel]
+
+ self._capacity = QEAmount()
+ self._local_capacity = QEAmount()
+ self._remote_capacity = QEAmount()
+ self._can_receive = QEAmount()
+ self._can_send = QEAmount()
+ self._is_closing = False
+
+ self.register_callbacks()
+ self.destroyed.connect(lambda: self.on_destroy())
+
+ @event_listener
+ def on_event_channel(self, wallet: 'Abstract_Wallet', channel: 'AbstractChannel'):
+ if wallet == self._wallet.wallet and self._channelid == channel.channel_id.hex():
+ self.channelChanged.emit()
+
+ def on_destroy(self):
+ self.unregister_callbacks()
+
+ walletChanged = pyqtSignal()
+ @pyqtProperty(QVariant, notify=walletChanged)
+ def wallet(self) -> QEWallet:
+ return self._wallet
+
+ @wallet.setter
+ def wallet(self, wallet: QEWallet):
+ assert wallet is None or isinstance(wallet, QEWallet)
+ if self._wallet != wallet:
+ self._wallet = wallet
+ self.walletChanged.emit()
+
+ channelidChanged = pyqtSignal()
+ @pyqtProperty(str, notify=channelidChanged)
+ def channelid(self) -> str:
+ return self._channelid
+
+ @channelid.setter
+ def channelid(self, channelid: str):
+ if self._channelid != channelid:
+ self._channelid = channelid
+ if channelid:
+ self.load()
+ self.channelidChanged.emit()
+
+ def load(self):
+ lnchannels = self._wallet.wallet.lnworker.get_channel_objects()
+ for channel in lnchannels.values():
+ if self._channelid == channel.channel_id.hex():
+ self._channel = channel
+ self.channelChanged.emit()
+
+ @pyqtProperty(str, notify=channelChanged)
+ def name(self) -> str:
+ if not self._channel:
+ return ''
+ return self._wallet.wallet.lnworker.lnpeermgr.get_node_alias(self._channel.node_id) or ''
+
+ @pyqtProperty(str, notify=channelChanged)
+ def pubkey(self) -> str:
+ return self._channel.node_id.hex()
+
+ @pyqtProperty(str, notify=channelChanged)
+ def shortCid(self) -> str:
+ return self._channel.short_id_for_GUI()
+
+ @pyqtProperty(str, notify=channelChanged)
+ def localScidAlias(self) -> str:
+ lsa = self._channel.get_local_scid_alias()
+ return format_short_id(lsa) if lsa else ''
+
+ @pyqtProperty(str, notify=channelChanged)
+ def remoteScidAlias(self) -> str:
+ rsa = self._channel.get_remote_scid_alias()
+ return format_short_id(rsa) if rsa else ''
+
+ @pyqtProperty(str, notify=channelChanged)
+ def currentFeerate(self) -> str:
+ if self._channel.is_backup():
+ return ''
+ assert isinstance(self._channel, Channel)
+ return self._wallet.wallet.config.format_fee_rate(4 * self._channel.get_latest_feerate(LOCAL))
+
+ @pyqtProperty(str, notify=channelChanged)
+ def state(self) -> str:
+ return self._channel.get_state_for_GUI()
+
+ @pyqtProperty(int, notify=channelChanged)
+ def stateCode(self) -> ChannelState:
+ return self._channel.get_state()
+
+ @pyqtProperty(str, notify=channelChanged)
+ def initiator(self) -> str:
+ if self._channel.is_backup():
+ return ''
+ assert isinstance(self._channel, Channel)
+ return 'Local' if self._channel.constraints.is_initiator else 'Remote'
+
+ @pyqtProperty('QVariantMap', notify=channelChanged)
+ def fundingOutpoint(self) -> dict:
+ outpoint = self._channel.funding_outpoint
+ return {
+ 'txid': outpoint.txid,
+ 'index': outpoint.output_index
+ }
+
+ @pyqtProperty(str, notify=channelChanged)
+ def closingTxid(self) -> str:
+ if not self._channel.is_closed():
+ return ''
+ item = self._channel.get_closing_height()
+ if item:
+ closing_txid, closing_height, timestamp = item
+ return closing_txid
+ else:
+ return ''
+
+ @pyqtProperty(QEAmount, notify=channelChanged)
+ def capacity(self) -> QEAmount:
+ self._capacity.copyFrom(QEAmount(amount_sat=self._channel.get_capacity()))
+ return self._capacity
+
+ @pyqtProperty(QEAmount, notify=channelChanged)
+ def localCapacity(self) -> QEAmount:
+ if not self._channel.is_backup():
+ self._local_capacity.copyFrom(QEAmount(amount_msat=self._channel.balance(LOCAL)))
+ return self._local_capacity
+
+ @pyqtProperty(QEAmount, notify=channelChanged)
+ def remoteCapacity(self) -> QEAmount:
+ if not self._channel.is_backup():
+ self._remote_capacity.copyFrom(QEAmount(amount_msat=self._channel.balance(REMOTE)))
+ return self._remote_capacity
+
+ @pyqtProperty(QEAmount, notify=channelChanged)
+ def canSend(self) -> QEAmount:
+ if not self._channel.is_backup():
+ self._can_send.copyFrom(QEAmount(amount_msat=self._channel.available_to_spend(LOCAL)))
+ return self._can_send
+
+ @pyqtProperty(QEAmount, notify=channelChanged)
+ def canReceive(self) -> QEAmount:
+ if not self._channel.is_backup():
+ self._can_receive.copyFrom(QEAmount(amount_msat=self._channel.available_to_spend(REMOTE)))
+ return self._can_receive
+
+ @pyqtProperty(bool, notify=channelChanged)
+ def frozenForSending(self) -> bool:
+ return self._channel.is_frozen_for_sending()
+
+ @pyqtProperty(bool, notify=channelChanged)
+ def frozenForReceiving(self) -> bool:
+ return self._channel.is_frozen_for_receiving()
+
+ @pyqtProperty(str, notify=channelChanged)
+ def channelType(self) -> str:
+ return self._channel.storage['channel_type'].name_minimal if 'channel_type' in self._channel.storage else 'Channel Backup'
+
+ @pyqtProperty(bool, notify=channelChanged)
+ def isOpen(self) -> bool:
+ return self._channel.is_open()
+
+ @pyqtProperty(bool, notify=channelChanged)
+ def canClose(self) -> bool:
+ return self.canCoopClose or self.canLocalForceClose or self.canRequestForceClose
+
+ @pyqtProperty(bool, notify=channelChanged)
+ def canCoopClose(self) -> bool:
+ return ChanCloseOption.COOP_CLOSE in self._channel.get_close_options()
+
+ @pyqtProperty(bool, notify=channelChanged)
+ def canLocalForceClose(self) -> bool:
+ return ChanCloseOption.LOCAL_FCLOSE in self._channel.get_close_options()
+
+ @pyqtProperty(bool, notify=channelChanged)
+ def canRequestForceClose(self) -> bool:
+ return ChanCloseOption.REQUEST_REMOTE_FCLOSE in self._channel.get_close_options()
+
+ @pyqtProperty(bool, notify=channelChanged)
+ def canDelete(self) -> bool:
+ return self._channel.can_be_deleted()
+
+ @pyqtProperty(str, notify=channelChanged)
+ def messageForceClose(self) -> str:
+ return messages.MSG_REQUEST_FORCE_CLOSE.strip()
+
+ @pyqtProperty(str, notify=channelChanged)
+ def messageForceCloseBackup(self):
+ return ' '.join([
+ _('If you force-close this channel, the funds you have in it will not be available for {} blocks.').format(self.toSelfDelay),
+ _('During that time, funds will not be recoverable from your seed, and may be lost if you lose your device.'),
+ _('To prevent that, please save this channel backup.'),
+ _('It may be imported in another wallet with the same seed.')
+ ])
+
+ @pyqtProperty(bool, notify=channelChanged)
+ def isBackup(self):
+ return self._channel.is_backup()
+
+ @pyqtProperty(str, notify=channelChanged)
+ def backupType(self):
+ if not self.isBackup:
+ return ''
+ assert isinstance(self._channel, ChannelBackup)
+ return 'imported' if self._channel.is_imported else 'on-chain'
+
+ @pyqtProperty(int, notify=channelChanged)
+ def toSelfDelay(self):
+ return self._channel.config[REMOTE].to_self_delay
+
+ @pyqtProperty(bool, notify=isClosingChanged)
+ def isClosing(self):
+ # Note: isClosing only applies to a closing action started by this instance, not
+ # whether the channel is closing
+ return self._is_closing
+
+ @pyqtSlot()
+ def freezeForSending(self):
+ assert isinstance(self._channel, Channel)
+ lnworker = self._channel.lnworker
+ if lnworker.channel_db or lnworker.is_trampoline_peer(self._channel.node_id):
+ self._channel.set_frozen_for_sending(not self.frozenForSending)
+ self.channelChanged.emit()
+ else:
+ self._logger.debug(messages.MSG_NON_TRAMPOLINE_CHANNEL_FROZEN_WITHOUT_GOSSIP)
+ self.trampolineFrozenInGossipMode.emit()
+
+ @pyqtSlot()
+ def freezeForReceiving(self):
+ assert isinstance(self._channel, Channel)
+ lnworker = self._channel.lnworker
+ if lnworker.channel_db or lnworker.is_trampoline_peer(self._channel.node_id):
+ self._channel.set_frozen_for_receiving(not self.frozenForReceiving)
+ self.channelChanged.emit()
+ else:
+ self._logger.debug(messages.MSG_NON_TRAMPOLINE_CHANNEL_FROZEN_WITHOUT_GOSSIP)
+
+ @pyqtSlot(str)
+ def closeChannel(self, closetype):
+ self.do_close_channel(closetype)
+
+ @auth_protect(message=_('Close Lightning channel?'))
+ def do_close_channel(self, closetype: str):
+ channel_id = self._channel.channel_id
+
+ def handle_result(success: bool, msg: str = ''):
+ try:
+ if success:
+ self.channelCloseSuccess.emit()
+ else:
+ self.channelCloseFailed.emit(msg)
+
+ self._is_closing = False
+ self.isClosingChanged.emit()
+ except RuntimeError: # QEChannelDetails might be deleted at this point if the user closed the dialog.
+ pass
+
+ def do_close():
+ try:
+ self._is_closing = True
+ self.isClosingChanged.emit()
+ if closetype == 'remote_force':
+ self._wallet.wallet.network.run_from_another_thread(self._wallet.wallet.lnworker.request_force_close(channel_id))
+ elif closetype == 'local_force':
+ self._wallet.wallet.network.run_from_another_thread(self._wallet.wallet.lnworker.force_close_channel(channel_id))
+ else:
+ self._wallet.wallet.network.run_from_another_thread(self._wallet.wallet.lnworker.close_channel(channel_id))
+ self._logger.debug('Channel close successful')
+ handle_result(True)
+ except Exception as e:
+ self._logger.exception("Could not close channel: " + repr(e))
+ handle_result(False, _('Could not close channel: ') + repr(e))
+
+ threading.Thread(target=do_close, daemon=True).start()
+
+ @pyqtSlot()
+ def deleteChannel(self):
+ if self.isBackup:
+ self._wallet.wallet.lnworker.remove_channel_backup(self._channel.channel_id)
+ else:
+ self._wallet.wallet.lnworker.remove_channel(self._channel.channel_id)
+
+ @pyqtSlot(result=str)
+ def channelBackup(self):
+ return self._wallet.wallet.lnworker.export_channel_backup(self._channel.channel_id)
+
+ @pyqtSlot(result=str)
+ def channelBackupHelpText(self):
+ return messages.MSG_LN_EXPLAIN_SCB_BACKUPS
diff --git a/electrum/gui/qml/qechannellistmodel.py b/electrum/gui/qml/qechannellistmodel.py
new file mode 100644
index 000000000000..0079cdcd5b4f
--- /dev/null
+++ b/electrum/gui/qml/qechannellistmodel.py
@@ -0,0 +1,213 @@
+from typing import TYPE_CHECKING
+
+from PyQt6.QtCore import Qt, QAbstractListModel, QModelIndex, pyqtProperty, pyqtSignal, pyqtSlot
+
+from electrum.lnchannel import ChannelState
+from electrum.lnutil import LOCAL, REMOTE
+from electrum.logging import get_logger
+from electrum.util import Satoshis
+from electrum.gui import messages
+
+from electrum.gui.common_qt.util import qt_event_listener, QtEventListener
+
+from .qetypes import QEAmount
+from .qemodelfilter import QEFilterProxyModel
+
+if TYPE_CHECKING:
+ from electrum.wallet import Abstract_Wallet
+
+
+class QEChannelListModel(QAbstractListModel, QtEventListener):
+ _logger = get_logger(__name__)
+
+ # define listmodel rolemap
+ _ROLE_NAMES=('cid', 'state', 'state_code', 'initiator', 'capacity', 'can_send',
+ 'can_receive', 'l_csv_delay', 'r_csv_delay', 'send_frozen', 'receive_frozen',
+ 'type', 'node_id', 'node_alias', 'short_cid', 'funding_tx', 'is_trampoline',
+ 'is_backup', 'is_imported', 'local_capacity', 'remote_capacity')
+ _ROLE_KEYS = range(Qt.ItemDataRole.UserRole, Qt.ItemDataRole.UserRole + len(_ROLE_NAMES))
+ _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES]))
+ _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS))
+
+ _network_signal = pyqtSignal(str, object)
+
+ def __init__(self, wallet: 'Abstract_Wallet', parent=None):
+ super().__init__(parent)
+ self.wallet = wallet
+ self._channels = []
+
+ self._fm_backups = None
+ self._fm_nobackups = None
+
+ self.initModel()
+
+ # 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.register_callbacks()
+ self.destroyed.connect(lambda: self.on_destroy())
+
+ @qt_event_listener
+ def on_event_channel(self, wallet, channel):
+ if wallet == self.wallet:
+ self.on_channel_updated(channel)
+
+ @qt_event_listener
+ def on_event_channels_updated(self, wallet):
+ if wallet == self.wallet:
+ self.initModel()
+
+ def on_destroy(self):
+ self.unregister_callbacks()
+
+ def rowCount(self, index):
+ return len(self._channels)
+
+ # also expose rowCount as a property
+ countChanged = pyqtSignal()
+ @pyqtProperty(int, notify=countChanged)
+ def count(self):
+ return len(self._channels)
+
+ def roleNames(self):
+ return self._ROLE_MAP
+
+ def data(self, index, role):
+ tx = self._channels[index.row()]
+ role_index = role - Qt.ItemDataRole.UserRole
+ value = tx[self._ROLE_NAMES[role_index]]
+ if isinstance(value, (bool, list, int, str, QEAmount)) or value is None:
+ return value
+ if isinstance(value, Satoshis):
+ return value.value
+ return str(value)
+
+ def clear(self):
+ self.beginResetModel()
+ self._channels = []
+ self.endResetModel()
+
+ def channel_to_model(self, lnc):
+ lnworker = self.wallet.lnworker
+ item = {
+ 'cid': lnc.channel_id.hex(),
+ 'node_id': lnc.node_id.hex(),
+ 'node_alias': lnworker.lnpeermgr.get_node_alias(lnc.node_id) or '',
+ 'short_cid': lnc.short_id_for_GUI(),
+ 'state': lnc.get_state_for_GUI(),
+ 'state_code': int(lnc.get_state()),
+ 'is_backup': lnc.is_backup(),
+ 'is_trampoline': lnworker.is_trampoline_peer(lnc.node_id),
+ 'capacity': QEAmount(amount_sat=lnc.get_capacity())
+ }
+ if lnc.is_backup():
+ item['can_send'] = QEAmount()
+ item['can_receive'] = QEAmount()
+ item['local_capacity'] = QEAmount()
+ item['remote_capacity'] = QEAmount()
+ item['send_frozen'] = True
+ item['receive_frozen'] = True
+ item['is_imported'] = lnc.is_imported
+ else:
+ item['can_send'] = QEAmount(amount_msat=lnc.available_to_spend(LOCAL))
+ item['can_receive'] = QEAmount(amount_msat=lnc.available_to_spend(REMOTE))
+ item['local_capacity'] = QEAmount(amount_msat=lnc.balance(LOCAL))
+ item['remote_capacity'] = QEAmount(amount_msat=lnc.balance(REMOTE))
+ item['send_frozen'] = lnc.is_frozen_for_sending()
+ item['receive_frozen'] = lnc.is_frozen_for_receiving()
+ item['is_imported'] = False
+ return item
+
+ numOpenChannelsChanged = pyqtSignal()
+ @pyqtProperty(int, notify=numOpenChannelsChanged)
+ def numOpenChannels(self):
+ return sum([1 if x['state_code'] == ChannelState.OPEN else 0 for x in self._channels])
+
+ @pyqtSlot()
+ def initModel(self):
+ self._logger.debug('init_model')
+ if not self.wallet.lnworker:
+ self._logger.warning('lnworker should be defined')
+ return
+
+ channels = []
+
+ lnchannels = self.wallet.lnworker.get_channel_objects()
+ for channel in lnchannels.values():
+ item = self.channel_to_model(channel)
+ channels.append(item)
+
+ # sort, for now simply by state
+ def chan_sort_score(c):
+ return c['state_code'] + (10 if c['is_backup'] else 0)
+ channels.sort(key=chan_sort_score)
+
+ self.clear()
+ self.beginInsertRows(QModelIndex(), 0, len(channels) - 1)
+ self._channels = channels
+ self.endInsertRows()
+
+ self.countChanged.emit()
+
+ def on_channel_updated(self, channel):
+ for i, c in enumerate(self._channels):
+ if c['cid'] == channel.channel_id.hex():
+ self.do_update(i, channel)
+ break
+
+ def do_update(self, modelindex, channel):
+ self._logger.debug(f'updating our channel {channel.short_id_for_GUI()}')
+ modelitem = self._channels[modelindex]
+ modelitem.update(self.channel_to_model(channel))
+
+ mi = self.createIndex(modelindex, 0)
+ self.dataChanged.emit(mi, mi, self._ROLE_KEYS)
+ self.numOpenChannelsChanged.emit()
+
+ @pyqtSlot(str)
+ def newChannel(self, cid):
+ self._logger.debug('new channel with cid %s' % cid)
+ lnchannels = self.wallet.lnworker.channels
+ for channel in lnchannels.values():
+ if cid == channel.channel_id.hex():
+ item = self.channel_to_model(channel)
+ self._logger.debug(item)
+ self.beginInsertRows(QModelIndex(), 0, 0)
+ self._channels.insert(0, item)
+ self.endInsertRows()
+ self.countChanged.emit()
+ return
+
+ @pyqtSlot(str)
+ def removeChannel(self, cid):
+ self._logger.debug('remove channel with cid %s' % cid)
+ for i, channel in enumerate(self._channels):
+ if cid == channel['cid']:
+ self._logger.debug(cid)
+ self.beginRemoveRows(QModelIndex(), i, i)
+ self._channels.remove(channel)
+ self.endRemoveRows()
+ self.countChanged.emit()
+ return
+
+ def filterModel(self, role, match):
+ _filterModel = QEFilterProxyModel(self, self)
+ assert role in self._ROLE_RMAP
+ _filterModel.setFilterRole(self._ROLE_RMAP[role])
+ _filterModel.setFilterValue(match)
+ return _filterModel
+
+ @pyqtSlot(result=QEFilterProxyModel)
+ def filterModelBackups(self):
+ self._fm_backups = self.filterModel('is_backup', True)
+ return self._fm_backups
+
+ @pyqtSlot(result=QEFilterProxyModel)
+ def filterModelNoBackups(self):
+ self._fm_nobackups = self.filterModel('is_backup', False)
+ return self._fm_nobackups
+
+ @pyqtSlot(result=str)
+ def lightningWarningMessage(self):
+ return messages.MSG_LIGHTNING_WARNING
diff --git a/electrum/gui/qml/qechannelopener.py b/electrum/gui/qml/qechannelopener.py
new file mode 100644
index 000000000000..957aa30d2e1f
--- /dev/null
+++ b/electrum/gui/qml/qechannelopener.py
@@ -0,0 +1,295 @@
+import threading
+from concurrent.futures import CancelledError
+from asyncio.exceptions import TimeoutError
+from typing import Optional
+import electrum_ecc as ecc
+
+from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QVariant
+
+from electrum.i18n import _
+from electrum.gui import messages
+from electrum.util import bfh
+from electrum.lnutil import MIN_FUNDING_SAT
+from electrum.lntransport import extract_nodeid, ConnStringFormatError
+from electrum.bitcoin import DummyAddress
+from electrum.lnworker import hardcoded_trampoline_nodes
+from electrum.logging import get_logger
+from electrum.transaction import PartialTransaction
+
+from .auth import AuthMixin, auth_protect
+from .qetxfinalizer import QETxFinalizer
+from .qetxdetails import QETxDetails
+from .qetypes import QEAmount
+from .qewallet import QEWallet
+
+
+class QEChannelOpener(QObject, AuthMixin):
+ _logger = get_logger(__name__)
+
+ validationError = pyqtSignal([str, str], arguments=['code', 'message'])
+ conflictingBackup = pyqtSignal([str], arguments=['message'])
+ channelOpening = pyqtSignal([str], arguments=['peer'])
+ channelOpenError = pyqtSignal([str], arguments=['message'])
+ channelOpenSuccess = pyqtSignal([str, bool, int, bool],
+ arguments=['cid', 'has_onchain_backup', 'min_depth', 'tx_complete'])
+
+ dataChanged = pyqtSignal() # generic notify signal
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ self._wallet = None # type: Optional[QEWallet]
+ self._connect_str = None
+ self._amount = QEAmount()
+ self._valid = False
+ self._opentx = None
+ self._txdetails = None
+ self._warning = ''
+ self._determine_max_message = None
+
+ self._finalizer = None
+ self._node_pubkey = None
+ self._connect_str_resolved = None
+
+ self._updating_max = False
+
+ walletChanged = pyqtSignal()
+ @pyqtProperty(QVariant, notify=walletChanged)
+ def wallet(self) -> QEWallet:
+ return self._wallet
+
+ @wallet.setter
+ def wallet(self, wallet: QEWallet):
+ assert wallet is None or isinstance(wallet, QEWallet)
+ if self._wallet != wallet:
+ self._wallet = wallet
+ self.walletChanged.emit()
+
+ connectStrChanged = pyqtSignal()
+ @pyqtProperty(str, notify=connectStrChanged)
+ def connectStr(self):
+ return self._connect_str
+
+ @connectStr.setter
+ def connectStr(self, connect_str: str):
+ if self._connect_str != connect_str:
+ self._logger.debug('connectStr set -> %s' % connect_str)
+ self._connect_str = connect_str
+ self.connectStrChanged.emit()
+ self.validate()
+
+ amountChanged = pyqtSignal()
+ @pyqtProperty(QVariant, notify=amountChanged)
+ def amount(self) -> QEAmount:
+ return self._amount
+
+ @amount.setter
+ def amount(self, amount: QEAmount):
+ assert amount is None or isinstance(amount, QEAmount)
+ if self._amount != amount:
+ self._amount.copyFrom(amount)
+ self.amountChanged.emit()
+ self.validate()
+
+ validChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=validChanged)
+ def valid(self):
+ return self._valid
+
+ def setValid(self, is_valid):
+ if self._valid != is_valid:
+ self._valid = is_valid
+ self.validChanged.emit()
+
+ warningChanged = pyqtSignal()
+ @pyqtProperty(str, notify=warningChanged)
+ def warning(self):
+ return self._warning
+
+ def setWarning(self, warning):
+ if self._warning != warning:
+ self._warning = warning
+ self.warningChanged.emit()
+
+ finalizerChanged = pyqtSignal()
+ @pyqtProperty(QETxFinalizer, notify=finalizerChanged)
+ def finalizer(self):
+ return self._finalizer
+
+ txDetailsChanged = pyqtSignal()
+ @pyqtProperty(QETxDetails, notify=txDetailsChanged)
+ def txDetails(self):
+ return self._txdetails
+
+ @pyqtProperty(list, notify=dataChanged)
+ def trampolineNodeNames(self):
+ return list(hardcoded_trampoline_nodes().keys())
+
+ # FIXME have requested funding amount
+ def validate(self):
+ """side-effects: sets self._node_pubkey, self._connect_str_resolved"""
+ connect_str_valid = False
+ if self._connect_str:
+ self._logger.debug(f'checking if {self._connect_str=!r} is valid')
+ if not self._wallet.wallet.config.LIGHTNING_USE_GOSSIP:
+ # using trampoline: connect_str is the name of a trampoline node
+ peer_addr = hardcoded_trampoline_nodes()[self._connect_str]
+ self._node_pubkey = peer_addr.pubkey
+ self._connect_str_resolved = str(peer_addr)
+ connect_str_valid = True
+ else:
+ # using gossip: connect_str is anything extract_nodeid() can parse
+ try:
+ self._node_pubkey, _rest = extract_nodeid(self._connect_str)
+ except ConnStringFormatError:
+ pass
+ else:
+ self._connect_str_resolved = self._connect_str
+ connect_str_valid = True
+
+ self.setWarning('')
+
+ if not connect_str_valid:
+ self.setValid(False)
+ return
+
+ self._logger.debug(f'amount={self._amount}')
+ if not self._amount or not (self._amount.satsInt > 0 or self._amount.isMax):
+ self.setValid(False)
+ return
+
+ # for MAX, estimate is assumed to be calculated and set in self._amount.satsInt
+ if self._amount.satsInt < MIN_FUNDING_SAT:
+ message = _('Minimum required amount: {}').format(
+ self._wallet.wallet.config.format_amount_and_units(MIN_FUNDING_SAT)
+ )
+ if self._amount.isMax and self._determine_max_message:
+ message += '\n' + self._determine_max_message
+ self.setWarning(message)
+ self.setValid(False)
+ return
+
+ if self._amount.satsInt > self._wallet.wallet.config.LIGHTNING_MAX_FUNDING_SAT:
+ self.setWarning(_('Amount is above maximum channel size: {}').format(
+ self._wallet.wallet.config.format_amount_and_units(self._wallet.wallet.config.LIGHTNING_MAX_FUNDING_SAT)
+ ))
+ self.setValid(False)
+ return
+
+ self.setValid(True)
+
+ @pyqtSlot(str, result=bool)
+ def validateConnectString(self, connect_str):
+ try:
+ extract_nodeid(connect_str)
+ except ConnStringFormatError as e:
+ self._logger.debug(f'invalid connect_str. {e!r}')
+ return False
+ return True
+
+ # FIXME "max" button in amount_dialog should enforce LIGHTNING_MAX_FUNDING_SAT
+ @pyqtSlot()
+ @pyqtSlot(bool)
+ def openChannel(self, confirm_backup_conflict=False):
+ if not self.valid:
+ return
+
+ self._logger.debug(f'Connect String: {self._connect_str!r}')
+
+ lnworker = self._wallet.wallet.lnworker
+ if lnworker.has_conflicting_backup_with(self._node_pubkey) and not confirm_backup_conflict:
+ self.conflictingBackup.emit(messages.MSG_CONFLICTING_BACKUP_INSTANCE)
+ return
+
+ amount = '!' if self._amount.isMax else self._amount.satsInt
+ self._logger.debug('amount = %s' % str(amount))
+
+ coins = self._wallet.wallet.get_spendable_coins(None, nonlocal_only=True)
+
+ mktx = lambda amt, fee_policy: lnworker.mktx_for_open_channel(
+ coins=coins,
+ funding_sat=amt,
+ node_id=self._node_pubkey,
+ fee_policy=fee_policy)
+
+ acpt = lambda tx: self.do_open_channel(tx, self._connect_str_resolved, self._wallet.password)
+
+ self._finalizer = QETxFinalizer(self, make_tx=mktx, accept=acpt)
+ self._finalizer.canRbf = False
+ self._finalizer.amount = self._amount
+ self._finalizer.wallet = self._wallet
+ self.finalizerChanged.emit()
+
+ @auth_protect(message=_('Open Lightning channel?'))
+ def do_open_channel(self, funding_tx: PartialTransaction, conn_str, password):
+ """
+ conn_str: a connection string that extract_nodeid can parse, i.e. cannot be a trampoline name
+ """
+ self._logger.debug('opening channel')
+ # read funding_sat from tx; converts '!' to int value
+ funding_sat = funding_tx.output_value_for_address(DummyAddress.CHANNEL)
+ lnworker = self._wallet.wallet.lnworker
+
+ def open_thread():
+ error = None
+ try:
+ chan, _funding_tx = lnworker.open_channel(
+ connect_str=conn_str,
+ funding_tx=funding_tx,
+ funding_sat=funding_sat,
+ push_amt_sat=0,
+ password=password)
+ self._logger.debug('opening channel succeeded')
+ self.channelOpenSuccess.emit(chan.channel_id.hex(), chan.has_onchain_backup(),
+ chan.constraints.funding_txn_minimum_depth, funding_tx.is_complete())
+
+ # TODO: handle incomplete TX
+ # if not funding_tx.is_complete():
+ # self._txdetails = QETxDetails(self)
+ # self._txdetails.rawTx = funding_tx
+ # self._txdetails.wallet = self._wallet
+ # self.txDetailsChanged.emit()
+
+ except (CancelledError, TimeoutError):
+ error = _('Could not connect to channel peer')
+ except Exception as e:
+ error = str(e)
+ if not error:
+ error = repr(e)
+ finally:
+ if error:
+ self._logger.exception("Problem opening channel: %s", error)
+ self.channelOpenError.emit(error)
+
+ self._logger.debug('starting open thread')
+ self.channelOpening.emit(conn_str)
+ threading.Thread(target=open_thread, daemon=True).start()
+
+ @pyqtSlot(str, result=str)
+ def channelBackup(self, cid):
+ return self._wallet.wallet.lnworker.export_channel_backup(bfh(cid))
+
+ @pyqtSlot()
+ def updateMaxAmount(self):
+ if self._updating_max:
+ return
+
+ self._updating_max = True
+
+ def calc_max():
+ try:
+ coins = self._wallet.wallet.get_spendable_coins(None, nonlocal_only=True)
+ dummy_nodeid = ecc.GENERATOR.get_public_key_bytes(compressed=True)
+ make_tx = lambda fee_policy: self._wallet.wallet.lnworker.mktx_for_open_channel(
+ coins=coins,
+ funding_sat='!',
+ node_id=dummy_nodeid,
+ fee_policy=fee_policy)
+
+ amount, self._determine_max_message = self._wallet.determine_max(mktx=make_tx)
+ self._amount.satsInt = amount if amount else 0
+ finally:
+ self._updating_max = False
+ self.validate()
+
+ threading.Thread(target=calc_max, daemon=True).start()
diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py
new file mode 100644
index 000000000000..d4ca6b9d5aa0
--- /dev/null
+++ b/electrum/gui/qml/qeconfig.py
@@ -0,0 +1,403 @@
+import copy
+from decimal import Decimal
+from typing import TYPE_CHECKING
+
+from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QRegularExpression
+
+from electrum.bitcoin import TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
+from electrum.i18n import set_language, get_gui_lang_names
+from electrum.logging import get_logger
+from electrum.util import base_unit_name_to_decimal_point
+from electrum.gui import messages
+
+from .qetypes import QEAmount
+from .auth import AuthMixin, auth_protect
+
+if TYPE_CHECKING:
+ from electrum.simple_config import SimpleConfig
+
+
+class QEConfig(AuthMixin, QObject):
+ instance = None # type: Optional[QEConfig]
+ _logger = get_logger(__name__)
+
+ def __init__(self, config: 'SimpleConfig', parent=None):
+ super().__init__(parent)
+ if QEConfig.instance:
+ raise RuntimeError('There should only be one QEConfig instance')
+ QEConfig.instance = self
+ self.config = config
+
+ @pyqtSlot(str, result=str)
+ def shortDescFor(self, key) -> str:
+ cv = getattr(self.config.cv, key)
+ return cv.get_short_desc() if cv else ''
+
+ @pyqtSlot(str, result=str)
+ def longDescFor(self, key) -> str:
+ cv = getattr(self.config.cv, key)
+ if not cv:
+ return ""
+ desc = cv.get_long_desc()
+ return messages.to_rtf(desc)
+
+ @pyqtSlot(str, result=str)
+ def getTranslatedMessage(self, key) -> str:
+ return getattr(messages, key)
+
+ languageChanged = pyqtSignal()
+ @pyqtProperty(str, notify=languageChanged)
+ def language(self):
+ return self.config.LOCALIZATION_LANGUAGE
+
+ @language.setter
+ def language(self, language):
+ if language not in get_gui_lang_names():
+ return
+ if self.config.LOCALIZATION_LANGUAGE != language:
+ self.config.LOCALIZATION_LANGUAGE = language
+ set_language(language)
+ self.languageChanged.emit()
+
+ languagesChanged = pyqtSignal()
+ @pyqtProperty('QVariantList', notify=languagesChanged)
+ def languagesAvailable(self):
+ langs = get_gui_lang_names()
+ langs_list = list(map(lambda x: {'value': x[0], 'text': x[1]}, langs.items()))
+ return langs_list
+
+ termsOfUseChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=termsOfUseChanged)
+ def termsOfUseAccepted(self) -> bool:
+ return self.config.TERMS_OF_USE_ACCEPTED >= messages.TERMS_OF_USE_LATEST_VERSION
+
+ @termsOfUseAccepted.setter
+ def termsOfUseAccepted(self, accepted: bool) -> None:
+ if accepted:
+ self.config.TERMS_OF_USE_ACCEPTED = messages.TERMS_OF_USE_LATEST_VERSION
+ else:
+ self.config.TERMS_OF_USE_ACCEPTED = 0
+ self.termsOfUseChanged.emit()
+
+ baseUnitChanged = pyqtSignal()
+ @pyqtProperty(str, notify=baseUnitChanged)
+ def baseUnit(self):
+ return self.config.get_base_unit()
+
+ @baseUnit.setter
+ def baseUnit(self, unit):
+ self.config.set_base_unit(unit)
+ self.baseUnitChanged.emit()
+
+ @pyqtProperty('QRegularExpression', notify=baseUnitChanged)
+ def btcAmountRegex(self):
+ return self._btcAmountRegex()
+
+ @pyqtProperty('QRegularExpression', notify=baseUnitChanged)
+ def btcAmountRegexMsat(self):
+ return self._btcAmountRegex(3)
+
+ def _btcAmountRegex(self, extra_precision: int = 0):
+ decimal_point = base_unit_name_to_decimal_point(self.config.get_base_unit())
+ max_digits_before_dp = (
+ len(str(TOTAL_COIN_SUPPLY_LIMIT_IN_BTC))
+ + (base_unit_name_to_decimal_point("BTC") - decimal_point))
+ exp = '^[0-9]{0,%d}' % max_digits_before_dp
+ decimal_point += extra_precision
+ if decimal_point > 0:
+ exp += '(\\.[0-9]{0,%d})?' % decimal_point
+ exp += '$'
+ return QRegularExpression(exp)
+
+ thousandsSeparatorChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=thousandsSeparatorChanged)
+ def thousandsSeparator(self):
+ return self.config.BTC_AMOUNTS_ADD_THOUSANDS_SEP
+
+ @thousandsSeparator.setter
+ def thousandsSeparator(self, checked):
+ self.config.BTC_AMOUNTS_ADD_THOUSANDS_SEP = checked
+ self.config.amt_add_thousands_sep = checked
+ self.thousandsSeparatorChanged.emit()
+
+ spendUnconfirmedChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=spendUnconfirmedChanged)
+ def spendUnconfirmed(self):
+ return not self.config.WALLET_SPEND_CONFIRMED_ONLY
+
+ @spendUnconfirmed.setter
+ def spendUnconfirmed(self, checked):
+ self.config.WALLET_SPEND_CONFIRMED_ONLY = not checked
+ self.spendUnconfirmedChanged.emit()
+
+ freezeReusedAddressUtxosChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=freezeReusedAddressUtxosChanged)
+ def freezeReusedAddressUtxos(self):
+ return self.config.WALLET_FREEZE_REUSED_ADDRESS_UTXOS
+
+ @freezeReusedAddressUtxos.setter
+ def freezeReusedAddressUtxos(self, checked):
+ self.config.WALLET_FREEZE_REUSED_ADDRESS_UTXOS = checked
+ self.freezeReusedAddressUtxosChanged.emit()
+
+ requestExpiryChanged = pyqtSignal()
+ @pyqtProperty(int, notify=requestExpiryChanged)
+ def requestExpiry(self):
+ return self.config.WALLET_PAYREQ_EXPIRY_SECONDS
+
+ @requestExpiry.setter
+ def requestExpiry(self, expiry):
+ self.config.WALLET_PAYREQ_EXPIRY_SECONDS = expiry
+ self.requestExpiryChanged.emit()
+
+ paymentAuthenticationChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=paymentAuthenticationChanged)
+ def paymentAuthentication(self):
+ return self.config.GUI_QML_PAYMENT_AUTHENTICATION
+
+ @paymentAuthentication.setter
+ def paymentAuthentication(self, enabled: bool):
+ if enabled:
+ self.config.GUI_QML_PAYMENT_AUTHENTICATION = True
+ self.paymentAuthenticationChanged.emit()
+ else:
+ self._disable_payment_authentication()
+
+ @auth_protect(method='wallet', reject='_payment_auth_reject')
+ def _disable_payment_authentication(self):
+ self.config.GUI_QML_PAYMENT_AUTHENTICATION = False
+ self.paymentAuthenticationChanged.emit()
+
+ def _payment_auth_reject(self):
+ self.paymentAuthenticationChanged.emit()
+
+ useGossipChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=useGossipChanged)
+ def useGossip(self):
+ return self.config.LIGHTNING_USE_GOSSIP
+
+ @useGossip.setter
+ def useGossip(self, gossip):
+ self.config.LIGHTNING_USE_GOSSIP = gossip
+ self.useGossipChanged.emit()
+
+ enableDebugLogsChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=enableDebugLogsChanged)
+ def enableDebugLogs(self):
+ gui_setting = self.config.GUI_ENABLE_DEBUG_LOGS
+ return gui_setting or bool(self.config.get('verbosity'))
+
+ @pyqtProperty(bool, notify=enableDebugLogsChanged)
+ def canToggleDebugLogs(self):
+ gui_setting = self.config.GUI_ENABLE_DEBUG_LOGS
+ return not self.config.get('verbosity') or gui_setting
+
+ @enableDebugLogs.setter
+ def enableDebugLogs(self, enable):
+ self.config.GUI_ENABLE_DEBUG_LOGS = enable
+ self.enableDebugLogsChanged.emit()
+
+ alwaysAllowScreenshotsChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=alwaysAllowScreenshotsChanged)
+ def alwaysAllowScreenshots(self):
+ return self.config.GUI_QML_ALWAYS_ALLOW_SCREENSHOTS
+
+ @alwaysAllowScreenshots.setter
+ def alwaysAllowScreenshots(self, enable):
+ self.config.GUI_QML_ALWAYS_ALLOW_SCREENSHOTS = enable
+ self.alwaysAllowScreenshotsChanged.emit()
+
+ setMaxBrightnessOnQrDisplayChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=setMaxBrightnessOnQrDisplayChanged)
+ def setMaxBrightnessOnQrDisplay(self):
+ return self.config.GUI_QML_SET_MAX_BRIGHTNESS_ON_QR_DISPLAY
+
+ @setMaxBrightnessOnQrDisplay.setter
+ def setMaxBrightnessOnQrDisplay(self, enable):
+ self.config.GUI_QML_SET_MAX_BRIGHTNESS_ON_QR_DISPLAY = enable
+
+ useRecoverableChannelsChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=useRecoverableChannelsChanged)
+ def useRecoverableChannels(self):
+ return self.config.LIGHTNING_USE_RECOVERABLE_CHANNELS
+
+ @useRecoverableChannels.setter
+ def useRecoverableChannels(self, useRecoverableChannels):
+ self.config.LIGHTNING_USE_RECOVERABLE_CHANNELS = useRecoverableChannels
+ self.useRecoverableChannelsChanged.emit()
+
+ trustedcoinPrepayChanged = pyqtSignal()
+ @pyqtProperty(int, notify=trustedcoinPrepayChanged)
+ def trustedcoinPrepay(self):
+ return self.config.PLUGIN_TRUSTEDCOIN_NUM_PREPAY
+
+ @trustedcoinPrepay.setter
+ def trustedcoinPrepay(self, num_prepay):
+ if num_prepay != self.config.PLUGIN_TRUSTEDCOIN_NUM_PREPAY:
+ self.config.PLUGIN_TRUSTEDCOIN_NUM_PREPAY = num_prepay
+ self.trustedcoinPrepayChanged.emit()
+
+ preferredRequestTypeChanged = pyqtSignal()
+ @pyqtProperty(str, notify=preferredRequestTypeChanged)
+ def preferredRequestType(self):
+ return self.config.GUI_QML_PREFERRED_REQUEST_TYPE
+
+ @preferredRequestType.setter
+ def preferredRequestType(self, preferred_request_type):
+ if preferred_request_type != self.config.GUI_QML_PREFERRED_REQUEST_TYPE:
+ self.config.GUI_QML_PREFERRED_REQUEST_TYPE = preferred_request_type
+ self.preferredRequestTypeChanged.emit()
+
+ userKnowsPressAndHoldChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=userKnowsPressAndHoldChanged)
+ def userKnowsPressAndHold(self):
+ return self.config.GUI_QML_USER_KNOWS_PRESS_AND_HOLD
+
+ @userKnowsPressAndHold.setter
+ def userKnowsPressAndHold(self, userKnowsPressAndHold):
+ if userKnowsPressAndHold != self.config.GUI_QML_USER_KNOWS_PRESS_AND_HOLD:
+ self.config.GUI_QML_USER_KNOWS_PRESS_AND_HOLD = userKnowsPressAndHold
+ self.userKnowsPressAndHoldChanged.emit()
+
+ addresslistShowTypeChanged = pyqtSignal()
+ @pyqtProperty(int, notify=addresslistShowTypeChanged)
+ def addresslistShowType(self):
+ return self.config.GUI_QML_ADDRESS_LIST_SHOW_TYPE
+
+ @addresslistShowType.setter
+ def addresslistShowType(self, addresslistShowType):
+ if addresslistShowType != self.config.GUI_QML_ADDRESS_LIST_SHOW_TYPE:
+ self.config.GUI_QML_ADDRESS_LIST_SHOW_TYPE = addresslistShowType
+ self.addresslistShowTypeChanged.emit()
+
+ addresslistShowUsedChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=addresslistShowUsedChanged)
+ def addresslistShowUsed(self):
+ return self.config.GUI_QML_ADDRESS_LIST_SHOW_USED
+
+ @addresslistShowUsed.setter
+ def addresslistShowUsed(self, addresslistShowUsed):
+ if addresslistShowUsed != self.config.GUI_QML_ADDRESS_LIST_SHOW_USED:
+ self.config.GUI_QML_ADDRESS_LIST_SHOW_USED = addresslistShowUsed
+ self.addresslistShowUsedChanged.emit()
+
+ outputValueRoundingChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=outputValueRoundingChanged)
+ def outputValueRounding(self):
+ return self.config.WALLET_COIN_CHOOSER_OUTPUT_ROUNDING
+
+ @outputValueRounding.setter
+ def outputValueRounding(self, outputValueRounding):
+ if outputValueRounding != self.config.WALLET_COIN_CHOOSER_OUTPUT_ROUNDING:
+ self.config.WALLET_COIN_CHOOSER_OUTPUT_ROUNDING = outputValueRounding
+ self.outputValueRoundingChanged.emit()
+
+ lightningPaymentFeeMaxMillionthsChanged = pyqtSignal()
+ @pyqtProperty(int, notify=lightningPaymentFeeMaxMillionthsChanged)
+ def lightningPaymentFeeMaxMillionths(self):
+ return self.config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS
+
+ @lightningPaymentFeeMaxMillionths.setter
+ def lightningPaymentFeeMaxMillionths(self, lightningPaymentFeeMaxMillionths):
+ if lightningPaymentFeeMaxMillionths != self.config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS:
+ self.config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS = lightningPaymentFeeMaxMillionths
+ self.lightningPaymentFeeMaxMillionthsChanged.emit()
+
+ nostrRelaysChanged = pyqtSignal()
+ @pyqtProperty(str, notify=nostrRelaysChanged)
+ def nostrRelays(self):
+ return self.config.NOSTR_RELAYS
+
+ @nostrRelays.setter
+ def nostrRelays(self, nostr_relays):
+ if nostr_relays != self.config.NOSTR_RELAYS:
+ self.config.NOSTR_RELAYS = nostr_relays if nostr_relays else None
+ self.nostrRelaysChanged.emit()
+
+ swapServerNPubChanged = pyqtSignal()
+ @pyqtProperty(str, notify=swapServerNPubChanged)
+ def swapServerNPub(self):
+ return self.config.SWAPSERVER_NPUB
+
+ @swapServerNPub.setter
+ def swapServerNPub(self, swapserver_npub):
+ if swapserver_npub != self.config.SWAPSERVER_NPUB:
+ self.config.SWAPSERVER_NPUB = swapserver_npub
+ self.swapServerNPubChanged.emit()
+
+ lnUtxoReserveChanged = pyqtSignal()
+ @pyqtProperty(QEAmount, notify=lnUtxoReserveChanged)
+ def lnUtxoReserve(self):
+ self._lnutxoreserve = QEAmount(amount_sat=self.config.LN_UTXO_RESERVE)
+ return self._lnutxoreserve
+
+ walletShouldUseSinglePasswordChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=walletShouldUseSinglePasswordChanged)
+ def walletShouldUseSinglePassword(self):
+ """
+ NOTE: this only indicates if we even want to use a single password, to check if we
+ actually use a single password the daemon needs to be checked.
+ """
+ return self.config.WALLET_SHOULD_USE_SINGLE_PASSWORD
+
+ walletDidUseSinglePasswordChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=walletDidUseSinglePasswordChanged)
+ def walletDidUseSinglePassword(self):
+ """
+ Allows to guess if this is a unified password instance without having
+ unlocked any wallet yet. Might be out of sync e.g. if wallet files get copied manually.
+ """
+ # TODO: consider removing once encrypted wallet file headers are available
+ return self.config.WALLET_DID_USE_SINGLE_PASSWORD
+
+ @pyqtSlot('qint64', result=str)
+ @pyqtSlot(QEAmount, result=str)
+ def formatSatsForEditing(self, satoshis):
+ if isinstance(satoshis, QEAmount):
+ satoshis = satoshis.satsInt
+ return self.config.format_amount(
+ satoshis,
+ add_thousands_sep=False,
+ )
+
+ @pyqtSlot('qint64', result=str)
+ @pyqtSlot('qint64', bool, result=str)
+ @pyqtSlot(QEAmount, result=str)
+ @pyqtSlot(QEAmount, bool, result=str)
+ def formatSats(self, satoshis, with_unit=False):
+ if isinstance(satoshis, QEAmount):
+ satoshis = satoshis.satsInt
+ if with_unit:
+ return self.config.format_amount_and_units(satoshis)
+ else:
+ return self.config.format_amount(satoshis)
+
+ @pyqtSlot(QEAmount, result=str)
+ @pyqtSlot(QEAmount, bool, result=str)
+ def formatMilliSats(self, amount, with_unit=False):
+ assert isinstance(amount, QEAmount), f"unexpected type for amount: {type(amount)}"
+ msats = amount.msatsInt
+ precision = 3 # config.amt_precision_post_satoshi is not exposed in preferences
+ if with_unit:
+ return self.config.format_amount_and_units(msats/1000, precision=precision)
+ else:
+ return self.config.format_amount(msats/1000, precision=precision)
+
+ @pyqtSlot(str, result=QEAmount)
+ def unitsToSats(self, unitAmount):
+ self._amount = QEAmount()
+ try:
+ x = Decimal(unitAmount)
+ except Exception:
+ return self._amount
+
+ sat_max_precision = self.config.BTC_AMOUNTS_DECIMAL_POINT
+ msat_max_precision = self.config.BTC_AMOUNTS_DECIMAL_POINT + 3
+ sat_max_prec_amount = int(pow(10, sat_max_precision) * x)
+ msat_max_prec_amount = int(pow(10, msat_max_precision) * x)
+ self._amount = QEAmount(amount_sat=sat_max_prec_amount, amount_msat=msat_max_prec_amount)
+ return self._amount
+
+ @pyqtSlot('quint64', result=float)
+ def satsToUnits(self, satoshis):
+ return satoshis / pow(10, self.config.BTC_AMOUNTS_DECIMAL_POINT)
diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py
new file mode 100644
index 000000000000..28a6640bd9d6
--- /dev/null
+++ b/electrum/gui/qml/qedaemon.py
@@ -0,0 +1,512 @@
+import base64
+import os
+import threading
+from typing import TYPE_CHECKING
+
+from PyQt6.QtCore import Qt, QAbstractListModel, QModelIndex
+from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject
+
+from electrum.i18n import _
+from electrum.logging import get_logger
+from electrum.util import WalletFileException, standardize_path, InvalidPassword, send_exception_to_crash_reporter
+from electrum.plugin import run_hook
+from electrum.lnchannel import ChannelState
+from electrum.bitcoin import is_address
+from electrum.bitcoin import verify_usermessage_with_address
+from electrum.storage import StorageReadWriteError, WalletStorage
+
+from .auth import AuthMixin, auth_protect
+from .qefx import QEFX
+from .qewallet import QEWallet
+from .qewizard import QENewWalletWizard, QEServerConnectWizard, QETermsOfUseWizard
+
+if TYPE_CHECKING:
+ from electrum.daemon import Daemon
+ from electrum.plugin import Plugins
+
+
+# wallet list model. supports both wallet basenames (wallet file basenames)
+# and whole Wallet instances (loaded wallets)
+from .util import check_password_strength
+
+
+class QEWalletListModel(QAbstractListModel):
+ _logger = get_logger(__name__)
+
+ # define listmodel rolemap
+ _ROLE_NAMES= ('name', 'path', 'active')
+ _ROLE_KEYS = range(Qt.ItemDataRole.UserRole, Qt.ItemDataRole.UserRole + len(_ROLE_NAMES))
+ _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES]))
+
+ def __init__(self, daemon: 'Daemon', parent=None):
+ QAbstractListModel.__init__(self, parent)
+ self.daemon = daemon
+ self._wallets = []
+ self.reload()
+
+ def rowCount(self, index):
+ return len(self._wallets)
+
+ def roleNames(self):
+ return self._ROLE_MAP
+
+ def data(self, index, role):
+ (wallet_name, wallet_path) = self._wallets[index.row()]
+ role_index = role - Qt.ItemDataRole.UserRole
+ role_name = self._ROLE_NAMES[role_index]
+ if role_name == 'name':
+ return wallet_name
+ if role_name == 'path':
+ return wallet_path
+ if role_name == 'active':
+ return self.daemon.get_wallet(wallet_path) is not None
+
+ @pyqtSlot()
+ def reload(self):
+ self._logger.debug('enumerating available wallets')
+ self.beginResetModel()
+ self._wallets = []
+ self.endResetModel()
+
+ available = []
+ wallet_folder = os.path.dirname(self.daemon.config.get_wallet_path())
+ with os.scandir(wallet_folder) as it:
+ for i in it:
+ if i.is_file() and not i.name.startswith('.'):
+ available.append(i.path)
+ for path in sorted(available):
+ wallet = self.daemon.get_wallet(path)
+ self.add_wallet(wallet_path=path)
+
+ def add_wallet(self, wallet_path):
+ self.beginInsertRows(QModelIndex(), len(self._wallets), len(self._wallets))
+ wallet_name = os.path.basename(wallet_path)
+ wallet_path = standardize_path(wallet_path)
+ item = (wallet_name, wallet_path)
+ self._wallets.append(item)
+ self.endInsertRows()
+
+ def remove_wallet(self, path):
+ i = 0
+ wallets = []
+ remove = -1
+ for wallet_name, wallet_path in self._wallets:
+ if wallet_path == path:
+ remove = i
+ else:
+ wallets.append((wallet_name, wallet_path))
+ i += 1
+
+ if remove >= 0:
+ self.beginRemoveRows(QModelIndex(), remove, remove)
+ self._wallets = wallets
+ self.endRemoveRows()
+
+ @pyqtSlot(str, result=bool)
+ def wallet_name_exists(self, name):
+ for wallet_name, wallet_path in self._wallets:
+ if name == wallet_name:
+ return True
+ return False
+
+ @pyqtSlot(str)
+ def updateWallet(self, path):
+ i = 0
+ for wallet_name, wallet_path in self._wallets:
+ if wallet_path == path:
+ mi = self.createIndex(i, i)
+ self.dataChanged.emit(mi, mi, self._ROLE_KEYS)
+ return
+ i += 1
+
+
+class QEDaemon(AuthMixin, QObject):
+ instance = None # type: Optional[QEDaemon]
+
+ _logger = get_logger(__name__)
+
+ _available_wallets = None
+ _current_wallet = None
+ _new_wallet_wizard = None
+ _terms_of_use_wizard = None
+ _server_connect_wizard = None
+ _path = None
+ _name = None
+ _use_single_password = False
+ _password = None
+ _loading = False
+
+ _backendWalletLoaded = pyqtSignal([str], arguments=['password'])
+
+ availableWalletsChanged = pyqtSignal()
+ fxChanged = pyqtSignal()
+ newWalletWizardChanged = pyqtSignal()
+ termsOfUseWizardChanged = pyqtSignal()
+ serverConnectWizardChanged = pyqtSignal()
+ loadingChanged = pyqtSignal()
+ requestNewPassword = pyqtSignal()
+
+ walletLoaded = pyqtSignal([str, str], arguments=['name', 'path'])
+ walletRequiresPassword = pyqtSignal([str, str], arguments=['name', 'path'])
+ walletOpenError = pyqtSignal([str], arguments=["error"])
+ walletDeleteError = pyqtSignal([str, str], arguments=['code', 'message'])
+ walletRenameError = pyqtSignal([str], arguments=['message'])
+
+ def __init__(self, daemon: 'Daemon', plugins: 'Plugins', parent=None):
+ super().__init__(parent)
+ if QEDaemon.instance:
+ raise RuntimeError('There should only be one QEDaemon instance')
+ QEDaemon.instance = self
+ self.daemon = daemon
+ self.plugins = plugins
+ self.qefx = QEFX(daemon.fx, daemon.config)
+
+ self._backendWalletLoaded.connect(self._on_backend_wallet_loaded)
+
+ @pyqtSlot()
+ def passwordValidityCheck(self):
+ if not self._walletdb._validPassword:
+ self.walletRequiresPassword.emit(self._name, self._path)
+
+ @pyqtSlot()
+ @pyqtSlot(str)
+ @pyqtSlot(str, str)
+ def loadWallet(self, path=None, password=None):
+ if self._loading:
+ return
+ self._loading = True
+
+ if path is None:
+ self._path = self.daemon.config.get('wallet_path') # command line -w option
+ if self._path is None:
+ self._path = self.daemon.config.CURRENT_WALLET
+ else:
+ self._path = path
+ if self._path is None:
+ self._loading = False
+ return
+
+ self.loadingChanged.emit()
+
+ self._path = standardize_path(self._path)
+ self._name = os.path.basename(self._path)
+
+ self._logger.debug('load wallet ' + str(self._path))
+
+ # password unification helper:
+ # - if pw not given (None), try pw of current wallet.
+ # - but "" empty str passwords are kept as-is, to open passwordless wallets
+ if password is None:
+ password = self._password
+
+ # map explicit empty str password to None. the backend disallows empty str passwords.
+ if password == '':
+ password = None
+
+ wallet_already_open = self.daemon.get_wallet(self._path)
+ if wallet_already_open is not None:
+ password = QEWallet.getInstanceFor(wallet_already_open).password
+
+ def load_wallet_task():
+ success = False
+ try:
+ local_password = password # need this in local scope
+ wallet = None
+ try:
+ wallet = self.daemon.load_wallet(
+ self._path,
+ password=local_password,
+ upgrade=True,
+ # might have a keystore password, but unencrypted storage. we want to prompt for pw even then:
+ force_check_password=True,
+ )
+ except InvalidPassword:
+ self.walletRequiresPassword.emit(self._name, self._path)
+ except FileNotFoundError:
+ self.walletOpenError.emit(_('File not found') + f":\n{self._path}")
+ except StorageReadWriteError:
+ self.walletOpenError.emit(_('Could not read/write file'))
+ except WalletFileException as e:
+ self.walletOpenError.emit(_('Could not open wallet: {}').format(str(e)))
+ if e.should_report_crash:
+ send_exception_to_crash_reporter(e)
+
+ if wallet is None:
+ return
+
+ if self.daemon.config.WALLET_SHOULD_USE_SINGLE_PASSWORD:
+ self._use_single_password = self._update_password_for_directory_and_unlock_wallets(old_password=local_password, new_password=local_password)
+ if not self._use_single_password and self.daemon.config.WALLET_ANDROID_USE_BIOMETRIC_AUTHENTICATION:
+ # we need to disable biometric auth if the user creates wallets with different passwords as
+ # we only store one encrypted password which is not associated to a specific wallet
+ self._logger.warning(f"disabling biometric authentication, not in single password mode")
+ self.daemon.config.WALLET_ANDROID_USE_BIOMETRIC_AUTHENTICATION = False
+ self.daemon.config.WALLET_ANDROID_BIOMETRIC_AUTH_WRAPPED_WALLET_PASSWORD = ''
+ self.daemon.config.WALLET_ANDROID_BIOMETRIC_AUTH_ENCRYPTED_WRAP_KEY = ''
+ self._password = local_password
+ self.singlePasswordChanged.emit()
+ self._logger.info(f'use single password: {self._use_single_password}')
+ else:
+ self._logger.info('use single password disabled by config')
+ self.daemon.config.WALLET_DID_USE_SINGLE_PASSWORD = self._use_single_password
+
+ run_hook('load_wallet', wallet)
+
+ success = True
+ self._backendWalletLoaded.emit(local_password)
+ finally:
+ if not success: # if successful, _loading guard will be reset by _on_backend_wallet_loaded
+ self._loading = False
+ self.loadingChanged.emit()
+
+ threading.Thread(target=load_wallet_task, daemon=False).start()
+
+ @pyqtSlot()
+ @pyqtSlot(str)
+ def _on_backend_wallet_loaded(self, password=None):
+ self._logger.debug('_on_backend_wallet_loaded')
+ wallet = self.daemon.get_wallet(self._path)
+ assert wallet is not None
+ self._current_wallet = QEWallet.getInstanceFor(wallet)
+ self.availableWallets.updateWallet(self._path)
+ wallet.unlock(password or None) # not conditional on wallet.requires_unlock in qml, as
+ # the auth wrapper doesn't pass the entered password, but instead we rely on the password in memory
+ self._loading = False
+ self.loadingChanged.emit()
+ self.walletLoaded.emit(self._name, self._path)
+
+ @pyqtSlot(QEWallet)
+ @pyqtSlot(QEWallet, bool)
+ @pyqtSlot(QEWallet, bool, bool)
+ def checkThenDeleteWallet(self, wallet, confirm_requests=False, confirm_balance=False):
+ if wallet.wallet.lnworker:
+ lnchannels = wallet.wallet.lnworker.get_channel_objects()
+ if any([channel.get_state() != ChannelState.REDEEMED and not channel.is_backup() for channel in lnchannels.values()]):
+ self.walletDeleteError.emit('unclosed_channels', _('There are still channels that are not fully closed'))
+ return
+
+ num_requests = len(wallet.wallet.get_unpaid_requests())
+ if num_requests > 0 and not confirm_requests:
+ self.walletDeleteError.emit('unpaid_requests', _('There are still unpaid requests. Really delete?'))
+ return
+
+ c, u, x = wallet.wallet.get_balance()
+ if c+u+x > 0 and not wallet.wallet.is_watching_only() and not confirm_balance:
+ self.walletDeleteError.emit('balance', _('There are still coins present in this wallet. Really delete?'))
+ return
+
+ self.delete_wallet(wallet)
+
+ @auth_protect(message=_('Really delete this wallet?'))
+ def delete_wallet(self, wallet):
+ path = standardize_path(wallet.wallet.storage.get_path())
+ self._logger.debug('deleting wallet with path %s' % path)
+ self._current_wallet = None
+ # TODO walletLoaded signal is confusing
+ self.walletLoaded.emit(None, None)
+
+ if not self.daemon.delete_wallet(path):
+ self.walletDeleteError.emit('error', _('Problem deleting wallet'))
+ return
+
+ self.availableWallets.remove_wallet(path)
+
+ def wallet_path_from_wallet_name(self, wallet_name: str) -> str:
+ return os.path.join(self.daemon.config.get_datadir_wallet_path(), wallet_name)
+
+ @pyqtSlot(str, result=bool)
+ def isValidWalletName(self, wallet_name: str) -> bool:
+ if not wallet_name:
+ return False
+ if self.availableWallets.wallet_name_exists(wallet_name):
+ return False
+ # note: we should probably restrict wallet names to be alphanumeric (plus underscore, etc)...
+ # try to prevent sketchy path traversals:
+ for forbidden_char in ("/", "\\", ):
+ if forbidden_char in wallet_name:
+ return False
+ if os.path.basename(wallet_name) != wallet_name: # '/foo/bar/' returns 'bar'
+ return False
+ wallet_path = self.wallet_path_from_wallet_name(wallet_name)
+ # validate that the path looks sane to the filesystem:
+ try:
+ temp_storage = WalletStorage(wallet_path)
+ except (StorageReadWriteError, WalletFileException):
+ return False
+ except Exception:
+ self._logger.exception("")
+ return False
+ if temp_storage.file_exists():
+ return False
+ return True
+
+ @pyqtSlot(str)
+ def renameWallet(self, new_name: str):
+ wallet = self._current_wallet
+ assert wallet, "name change without wallet?"
+ old_path = standardize_path(wallet.wallet.storage.get_path())
+ wallet_dir = os.path.dirname(old_path)
+ new_path = standardize_path(os.path.join(wallet_dir, new_name))
+ if old_path == new_path:
+ return
+ self._current_wallet = None
+ self.daemon.stop_wallet(old_path)
+ try:
+ self.daemon.rename_wallet_file(old_path, new_path)
+ except Exception as e:
+ self.walletRenameError.emit(_('Error renaming wallet:\n') + str(e))
+ self.walletLoaded.emit(None, None)
+
+ @pyqtProperty(bool, notify=loadingChanged)
+ def loading(self):
+ return self._loading
+
+ @pyqtProperty(QEWallet, notify=walletLoaded)
+ def currentWallet(self):
+ return self._current_wallet
+
+ @pyqtProperty(QEWalletListModel, notify=availableWalletsChanged)
+ def availableWallets(self):
+ if not self._available_wallets:
+ self._available_wallets = QEWalletListModel(self.daemon)
+
+ return self._available_wallets
+
+ @pyqtProperty(QEFX, notify=fxChanged)
+ def fx(self):
+ return self.qefx
+
+ @pyqtSlot(str, result=list)
+ def getWalletsUnlockableWithPassword(self, password: str) -> list[str]:
+ """
+ Returns any wallet that can be unlocked with the given password.
+ Can be used as fallback to unlock another wallet the user entered a
+ password that doesn't work for the current wallet but might work for another one.
+ """
+ wallet_dir = os.path.dirname(self.daemon.config.get_wallet_path())
+ _, _, wallet_paths_can_unlock = self.daemon.check_password_for_directory(
+ old_password=password,
+ new_password=None,
+ wallet_dir=wallet_dir,
+ )
+ if not wallet_paths_can_unlock:
+ return []
+ self._logger.debug(f"getWalletsUnlockableWithPassword: can unlock {len(wallet_paths_can_unlock)} wallets")
+ return [str(path) for path in wallet_paths_can_unlock]
+
+ @pyqtSlot(str, result=int)
+ def numWalletsWithPassword(self, password: str) -> int:
+ """Returns the number of wallets that can be unlocked with the given password"""
+ wallet_paths_can_unlock = self.getWalletsUnlockableWithPassword(password)
+ return len(wallet_paths_can_unlock)
+
+ singlePasswordChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=singlePasswordChanged)
+ def singlePasswordEnabled(self):
+ """
+ singlePasswordEnabled is False if:
+ a.) the user has no wallet (and password) yet
+ b.) the user has wallets with different passwords (legacy)
+ c.) all wallets are locked, we couldn't check yet if they all use the same password
+ d.) we are on desktop where different passwords are allowed
+ """
+ return self._use_single_password
+
+ @pyqtProperty(str, notify=singlePasswordChanged)
+ def singlePassword(self):
+ """
+ self._password is also set to the last loaded wallet password if we WANT a single password,
+ but don't actually have a single password yet. So singlePassword being set doesn't strictly
+ mean all wallets use the same password.
+ """
+ return self._password
+
+ @singlePassword.setter
+ def singlePassword(self, password: str):
+ assert password
+ assert self.daemon.config.WALLET_SHOULD_USE_SINGLE_PASSWORD
+ if self._password != password:
+ self._password = password
+ self.singlePasswordChanged.emit()
+
+ @pyqtSlot(result=str)
+ def suggestWalletName(self):
+ # FIXME why not use util.get_new_wallet_name ?
+ i = 1
+ while self.availableWallets.wallet_name_exists(f'wallet_{i}'):
+ i = i + 1
+ return f'wallet_{i}'
+
+ @pyqtSlot()
+ @auth_protect(method='wallet_password_only')
+ def startChangePassword(self):
+ if self._use_single_password:
+ self.requestNewPassword.emit()
+ else:
+ self.currentWallet.requestNewPassword.emit()
+
+ @pyqtSlot(str, result=bool)
+ def setPassword(self, password):
+ assert self._use_single_password
+ assert password
+ if not self._update_password_for_directory_and_unlock_wallets(old_password=self._password, new_password=password):
+ return False
+ self._password = password
+ return True
+
+ def _update_password_for_directory_and_unlock_wallets(self, *, old_password, new_password):
+ # note: this assumes all wallet files are in a single directory.
+ # change wallet passwords:
+ ret = self.daemon.update_password_for_directory(old_password=old_password, new_password=new_password)
+ # If some wallets just had their password changed, they got "locked" by wallet.update_password().
+ # If the password is not unified yet, other loaded wallets might still be unlocked.
+ # restore the invariant that all loaded wallets in qml must be unlocked:
+ for w in self.daemon.get_wallets().values():
+ if not w.is_unlocked():
+ w.unlock(new_password)
+ assert w.is_unlocked()
+ return ret
+
+ @pyqtProperty(QENewWalletWizard, notify=newWalletWizardChanged)
+ def newWalletWizard(self):
+ if not self._new_wallet_wizard:
+ self._new_wallet_wizard = QENewWalletWizard(self, self.plugins)
+
+ return self._new_wallet_wizard
+
+ @pyqtProperty(QEServerConnectWizard, notify=serverConnectWizardChanged)
+ def serverConnectWizard(self):
+ if not self._server_connect_wizard:
+ self._server_connect_wizard = QEServerConnectWizard(self)
+
+ return self._server_connect_wizard
+
+ @pyqtProperty(QETermsOfUseWizard, notify=termsOfUseWizardChanged)
+ def termsOfUseWizard(self):
+ if not self._terms_of_use_wizard:
+ self._terms_of_use_wizard = QETermsOfUseWizard(self)
+ return self._terms_of_use_wizard
+
+ @pyqtSlot()
+ def startNetwork(self):
+ self.daemon.start_network()
+
+ @pyqtSlot(str, str, str, result=bool)
+ def verifyMessage(self, address, message, signature):
+ address = address.strip()
+ message = message.strip().encode('utf-8')
+ if not is_address(address):
+ return False
+ try:
+ # This can throw on invalid base64
+ sig = base64.b64decode(str(signature.strip()), validate=True)
+ verified = verify_usermessage_with_address(address, sig, message)
+ except Exception as e:
+ verified = False
+ return verified
+
+ @pyqtSlot(str, result=int)
+ def passwordStrength(self, password):
+ if len(password) == 0:
+ return 0
+ return check_password_strength(password)[0]
diff --git a/electrum/gui/qml/qefx.py b/electrum/gui/qml/qefx.py
new file mode 100644
index 000000000000..8225a3a62fed
--- /dev/null
+++ b/electrum/gui/qml/qefx.py
@@ -0,0 +1,180 @@
+from datetime import datetime, timedelta
+from decimal import Decimal
+
+from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QRegularExpression
+
+from electrum.bitcoin import COIN
+from electrum.exchange_rate import FxThread
+from electrum.logging import get_logger
+from electrum.simple_config import SimpleConfig
+from electrum.util import event_listener
+
+from electrum.gui.common_qt.util import QtEventListener
+
+from .qetypes import QEAmount
+
+
+class QEFX(QObject, QtEventListener):
+ _logger = get_logger(__name__)
+
+ quotesUpdated = pyqtSignal()
+
+ def __init__(self, fxthread: FxThread, config: SimpleConfig, parent=None):
+ super().__init__(parent)
+ self.fx = fxthread
+ self.config = config
+ self.register_callbacks()
+ self.destroyed.connect(lambda: self.on_destroy())
+
+ def on_destroy(self):
+ self.unregister_callbacks()
+
+ @event_listener
+ def on_event_on_quotes(self, *args):
+ self._logger.debug('new quotes')
+ self.quotesUpdated.emit()
+
+ historyUpdated = pyqtSignal()
+ @event_listener
+ def on_event_on_history(self, *args):
+ self._logger.debug('new history')
+ self.historyUpdated.emit()
+
+ currenciesChanged = pyqtSignal()
+ @pyqtProperty('QVariantList', notify=currenciesChanged)
+ def currencies(self):
+ return self.fx.get_currencies(self.historicRates)
+
+ rateSourcesChanged = pyqtSignal()
+ @pyqtProperty('QVariantList', notify=rateSourcesChanged)
+ def rateSources(self):
+ return self.fx.get_exchanges_by_ccy(self.fiatCurrency, self.historicRates)
+
+ fiatCurrencyChanged = pyqtSignal()
+ @pyqtProperty(str, notify=fiatCurrencyChanged)
+ def fiatCurrency(self):
+ return self.fx.get_currency()
+
+ @fiatCurrency.setter
+ def fiatCurrency(self, currency):
+ if currency != self.fiatCurrency:
+ self.fx.set_currency(currency)
+ self.enabled = self.enabled and currency != ''
+ self.fiatCurrencyChanged.emit()
+ self.rateSourcesChanged.emit()
+
+ @pyqtProperty('QRegularExpression', notify=fiatCurrencyChanged)
+ def fiatAmountRegex(self):
+ decimals = self.fx.ccy_precision()
+ exp = '[0-9]*'
+ if decimals:
+ exp += '\\.'
+ exp += '[0-9]{0,%d}' % decimals
+ return QRegularExpression(exp)
+
+ historicRatesChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=historicRatesChanged)
+ def historicRates(self):
+ if not self.fx.config.cv.FX_HISTORY_RATES.is_set():
+ self.fx.config.FX_HISTORY_RATES = True # override default
+ return self.fx.config.FX_HISTORY_RATES
+
+ @historicRates.setter
+ def historicRates(self, checked):
+ if checked != self.historicRates:
+ self.fx.config.FX_HISTORY_RATES = bool(checked)
+ self.historicRatesChanged.emit()
+ self.rateSourcesChanged.emit()
+
+ rateSourceChanged = pyqtSignal()
+ @pyqtProperty(str, notify=rateSourceChanged)
+ def rateSource(self):
+ return self.fx.config_exchange()
+
+ @rateSource.setter
+ def rateSource(self, source):
+ if source != self.rateSource:
+ self.fx.set_exchange(source)
+ self.rateSourceChanged.emit()
+
+ enabledUpdated = pyqtSignal() # curiously, enabledChanged is clashing, so name it enabledUpdated
+ @pyqtProperty(bool, notify=enabledUpdated)
+ def enabled(self):
+ return self.fx.is_enabled()
+
+ @enabled.setter
+ def enabled(self, enable):
+ if enable != self.enabled:
+ self.fx.set_enabled(enable)
+ self.enabledUpdated.emit()
+
+ @pyqtSlot(str, result=str)
+ @pyqtSlot(str, bool, result=str)
+ @pyqtSlot(QEAmount, result=str)
+ @pyqtSlot(QEAmount, bool, result=str)
+ def fiatValue(self, satoshis, plain=True):
+ rate = self.fx.exchange_rate()
+ if isinstance(satoshis, QEAmount):
+ satoshis = satoshis.msatsInt / 1000 if satoshis.msatsInt != 0 else satoshis.satsInt
+ else:
+ try:
+ sd = Decimal(satoshis)
+ except Exception:
+ return ''
+ if plain:
+ return self.fx.ccy_amount_str(self.fx.fiat_value(satoshis, rate), add_thousands_sep=False)
+ else:
+ return self.fx.value_str(satoshis, rate)
+
+ @pyqtSlot(str, str, result=str)
+ @pyqtSlot(str, str, bool, result=str)
+ @pyqtSlot(QEAmount, str, result=str)
+ @pyqtSlot(QEAmount, str, bool, result=str)
+ def fiatValueHistoric(self, satoshis, timestamp, plain=True):
+ if isinstance(satoshis, QEAmount):
+ satoshis = satoshis.msatsInt / 1000 if satoshis.msatsInt != 0 else satoshis.satsInt
+ else:
+ try:
+ sd = Decimal(satoshis)
+ except Exception:
+ return ''
+
+ try:
+ td = Decimal(timestamp)
+ if td == 0:
+ return ''
+ except Exception:
+ return ''
+ dt = datetime.fromtimestamp(int(td))
+ if plain:
+ return self.fx.ccy_amount_str(self.fx.historical_value(satoshis, dt), add_thousands_sep=False)
+ else:
+ return self.fx.historical_value_str(satoshis, dt)
+
+ @pyqtSlot(str, result=str)
+ @pyqtSlot(str, bool, result=str)
+ def satoshiValue(self, fiat, plain=True):
+ rate = self.fx.exchange_rate()
+ try:
+ fd = Decimal(fiat)
+ except Exception:
+ return ''
+ v = fd / Decimal(rate) * COIN
+ if v.is_nan():
+ return ''
+ if plain:
+ return str(v.to_integral_value())
+ else:
+ return self.config.format_amount(v)
+
+ @pyqtSlot(str, result=bool)
+ def isRecent(self, timestamp):
+ # return True if unknown, e.g. timestamp not known yet, tx in mempool
+ try:
+ td = Decimal(timestamp)
+ if td == 0:
+ return True
+ except Exception:
+ return True
+ dt = datetime.fromtimestamp(int(td))
+ return dt + timedelta(days=1) > datetime.today()
diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py
new file mode 100644
index 000000000000..7069663f3441
--- /dev/null
+++ b/electrum/gui/qml/qeinvoice.py
@@ -0,0 +1,675 @@
+import copy
+import threading
+from enum import IntEnum
+from typing import Optional, Dict, Any, Tuple
+from urllib.parse import urlparse
+
+from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, pyqtEnum, QTimer, QVariant
+
+from electrum.i18n import _
+from electrum.logging import get_logger
+from electrum.invoices import (
+ Invoice, PR_UNPAID, PR_EXPIRED, PR_UNKNOWN, PR_PAID, PR_INFLIGHT, PR_FAILED, PR_ROUTING, PR_UNCONFIRMED,
+ PR_BROADCASTING, PR_BROADCAST, LN_EXPIRY_NEVER
+)
+from electrum.transaction import PartialTxOutput, TxOutput
+from electrum.lnutil import format_short_channel_id
+from electrum.lnurl import LNURL6Data
+from electrum.bitcoin import COIN, address_to_script
+from electrum.payment_identifier import PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType
+from electrum.network import Network
+from electrum.util import event_listener
+
+from electrum.gui.common_qt.util import QtEventListener
+
+from .qetypes import QEAmount
+from .qewallet import QEWallet
+from .util import status_update_timer_interval
+from ...util import InvoiceError
+
+
+class QEInvoice(QObject, QtEventListener):
+ @pyqtEnum
+ class Type(IntEnum):
+ Invalid = -1
+ OnchainInvoice = 0
+ LightningInvoice = 1
+ LNURLPayRequest = 2
+
+ @pyqtEnum
+ class Status(IntEnum):
+ Unpaid = PR_UNPAID
+ Expired = PR_EXPIRED
+ Unknown = PR_UNKNOWN
+ Paid = PR_PAID
+ Inflight = PR_INFLIGHT
+ Failed = PR_FAILED
+ Routing = PR_ROUTING
+ Unconfirmed = PR_UNCONFIRMED
+
+ _logger = get_logger(__name__)
+
+ invoiceChanged = pyqtSignal()
+ invoiceSaved = pyqtSignal([str], arguments=['key'])
+ amountOverrideChanged = pyqtSignal()
+ maxAmountMessage = pyqtSignal([str], arguments=['message'])
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ self._wallet = None # type: Optional[QEWallet]
+ self._isSaved = False
+ self._canSave = False
+ self._canPay = False
+ self._key = None
+ self._invoiceType = QEInvoice.Type.Invalid
+ self._effectiveInvoice = None # type: Optional[Invoice]
+ self._userinfo = ''
+ self._paid_in_this_session = False
+ self._lnprops = {}
+ self._amount = QEAmount()
+ self._amountOverride = QEAmount()
+
+ self._timer = QTimer(self)
+ self._timer.setSingleShot(True)
+ self._timer.timeout.connect(self.updateStatusString)
+
+ self._amountOverride.valueChanged.connect(self._on_amountoverride_value_changed)
+
+ self._updating_max = False
+
+ self.register_callbacks()
+ self.destroyed.connect(lambda: self.on_destroy())
+
+ def on_destroy(self):
+ self.unregister_callbacks()
+
+ @event_listener
+ def on_event_payment_succeeded(self, wallet, key):
+ if wallet == self._wallet.wallet and key == self.key:
+ self.statusChanged.emit()
+ self.determine_can_pay()
+ self.update_userinfo()
+
+ @event_listener
+ def on_event_payment_failed(self, wallet, key, reason):
+ if wallet == self._wallet.wallet and key == self.key:
+ self.statusChanged.emit()
+ self.determine_can_pay()
+ self.userinfo = _('Payment failed: ') + reason
+
+ @event_listener
+ def on_event_invoice_status(self, wallet, key, status):
+ if self._wallet and wallet == self._wallet.wallet and key == self.key:
+ self.update_userinfo()
+ self.determine_can_pay()
+ self.statusChanged.emit()
+
+ @event_listener
+ def on_event_channel(self, wallet, channel):
+ if self._wallet and wallet == self._wallet.wallet:
+ self.update_userinfo()
+ self.determine_can_pay()
+
+ walletChanged = pyqtSignal()
+ @pyqtProperty(QVariant, notify=walletChanged)
+ def wallet(self) -> QEWallet:
+ return self._wallet
+
+ @wallet.setter
+ def wallet(self, wallet: QEWallet):
+ assert wallet is None or isinstance(wallet, QEWallet)
+ if self._wallet != wallet:
+ self._wallet = wallet
+ self.walletChanged.emit()
+
+ @pyqtProperty(int, notify=invoiceChanged)
+ def invoiceType(self):
+ return self._invoiceType
+
+ # not a qt setter, don't let outside set state
+ def setInvoiceType(self, invoiceType: Type):
+ self._invoiceType = invoiceType
+
+ @pyqtProperty(str, notify=invoiceChanged)
+ def message(self):
+ return self._effectiveInvoice.message if self._effectiveInvoice else ''
+
+ @pyqtProperty('quint64', notify=invoiceChanged)
+ def time(self):
+ return self._effectiveInvoice.time if self._effectiveInvoice else 0
+
+ @pyqtProperty('quint64', notify=invoiceChanged)
+ def expiration(self):
+ return self._effectiveInvoice.exp if self._effectiveInvoice else 0
+
+ @pyqtProperty(str, notify=invoiceChanged)
+ def address(self):
+ return self._effectiveInvoice.get_address() if self._effectiveInvoice else ''
+
+ @pyqtProperty(QEAmount, notify=invoiceChanged)
+ def amount(self):
+ if not self._effectiveInvoice:
+ self._amount.clear()
+ return self._amount
+ self._amount.copyFrom(QEAmount(from_invoice=self._effectiveInvoice))
+ return self._amount
+
+ @pyqtProperty(QVariant, notify=amountOverrideChanged)
+ def amountOverride(self) -> QEAmount:
+ return self._amountOverride
+
+ @amountOverride.setter
+ def amountOverride(self, new_amount: QEAmount):
+ assert new_amount is None or isinstance(new_amount, QEAmount)
+ self._logger.debug(f'set new override amount {repr(new_amount)}')
+ self._amountOverride.copyFrom(new_amount)
+ self.amountOverrideChanged.emit()
+
+ @pyqtSlot()
+ def _on_amountoverride_value_changed(self):
+ self.update_userinfo()
+ self.determine_can_pay()
+
+ statusChanged = pyqtSignal()
+ @pyqtProperty(int, notify=statusChanged)
+ def status(self):
+ if not self._effectiveInvoice:
+ return PR_UNKNOWN
+ if self.invoiceType == QEInvoice.Type.OnchainInvoice and self._effectiveInvoice.get_amount_sat() == 0:
+ # no amount set, not a final invoice, get_invoice_status would be wrong
+ return PR_UNPAID
+ return self._wallet.wallet.get_invoice_status(self._effectiveInvoice)
+
+ @pyqtProperty(str, notify=statusChanged)
+ def statusString(self):
+ if not self._effectiveInvoice:
+ return ''
+ status = self._wallet.wallet.get_invoice_status(self._effectiveInvoice)
+ return self._effectiveInvoice.get_status_str(status)
+
+ isSavedChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=isSavedChanged)
+ def isSaved(self):
+ return self._isSaved
+
+ canSaveChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=canSaveChanged)
+ def canSave(self):
+ return self._canSave
+
+ @canSave.setter
+ def canSave(self, canSave):
+ if self._canSave != canSave:
+ self._canSave = canSave
+ self.canSaveChanged.emit()
+
+ canPayChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=canPayChanged)
+ def canPay(self):
+ return self._canPay
+
+ @canPay.setter
+ def canPay(self, canPay):
+ if self._canPay != canPay:
+ self._canPay = canPay
+ self.canPayChanged.emit()
+
+ keyChanged = pyqtSignal()
+ @pyqtProperty(str, notify=keyChanged)
+ def key(self):
+ return self._key
+
+ @key.setter
+ def key(self, key):
+ self._key = key
+ invoice = copy.copy(self._wallet.wallet.get_invoice(key)) # copy, so any mutations stay out of wallet invoice list
+ self._logger.debug(f'invoice from key {key}: {repr(invoice)}')
+ self.set_effective_invoice(invoice)
+ self.keyChanged.emit()
+
+ userinfoChanged = pyqtSignal()
+ @pyqtProperty(str, notify=userinfoChanged)
+ def userinfo(self):
+ return self._userinfo
+
+ @userinfo.setter
+ def userinfo(self, userinfo):
+ if self._userinfo != userinfo:
+ self._userinfo = userinfo
+ self.userinfoChanged.emit()
+
+ @pyqtProperty('QVariantMap', notify=invoiceChanged)
+ def lnprops(self):
+ return self._lnprops
+
+ def set_lnprops(self):
+ self._lnprops = {}
+ if not self.invoiceType == QEInvoice.Type.LightningInvoice:
+ return
+
+ lnaddr = self._effectiveInvoice._lnaddr
+ ln_routing_info = lnaddr.get_routing_info('r')
+ self._logger.debug(str(ln_routing_info))
+
+ self._lnprops = {
+ 'pubkey': lnaddr.pubkey.serialize().hex(),
+ 'payment_hash': lnaddr.paymenthash.hex(),
+ 'r': [{
+ 'node': self.name_for_node_id(x[-1][0]),
+ 'scid': format_short_channel_id(x[-1][1])
+ } for x in ln_routing_info] if ln_routing_info else []
+ }
+
+ def name_for_node_id(self, node_id):
+ lnworker = self._wallet.wallet.lnworker
+ return (lnworker.lnpeermgr.get_node_alias(node_id) if lnworker else None) or node_id.hex()
+
+ def set_effective_invoice(self, invoice: Invoice):
+ self._paid_in_this_session = False
+ self._effectiveInvoice = invoice
+
+ if invoice is None:
+ self.setInvoiceType(QEInvoice.Type.Invalid)
+ else:
+ if invoice.is_lightning():
+ self.setInvoiceType(QEInvoice.Type.LightningInvoice)
+ else:
+ self.setInvoiceType(QEInvoice.Type.OnchainInvoice)
+ self._isSaved = self._wallet.wallet.get_invoice(invoice.get_id()) is not None
+
+ self.set_lnprops()
+
+ self.update_userinfo()
+ self.determine_can_pay()
+
+ self.invoiceChanged.emit()
+ self.statusChanged.emit()
+ self.isSavedChanged.emit()
+
+ self.set_status_timer()
+
+ def set_status_timer(self):
+ if self.status != PR_EXPIRED:
+ if self.expiration > 0 and self.expiration != LN_EXPIRY_NEVER:
+ interval = status_update_timer_interval(self.time + self.expiration)
+ if interval > 0:
+ self._timer.setInterval(interval) # msec
+ self._timer.start()
+ else:
+ self.update_userinfo()
+ self.determine_can_pay() # status went to PR_EXPIRED
+
+ @pyqtSlot()
+ def updateStatusString(self):
+ self.statusChanged.emit()
+ self.set_status_timer()
+
+ def update_userinfo(self):
+ self.userinfo = ''
+
+ if not self.amountOverride.isEmpty:
+ amount = self.amountOverride
+ else:
+ amount = self.amount
+
+ if self.amount.isEmpty:
+ self.userinfo = _('Enter the amount you want to send')
+
+ status = self.status
+
+ if amount.isEmpty and status == PR_UNPAID: # unspecified amount
+ return
+
+ def userinfo_for_invoice_status(_status: int) -> str:
+ return {
+ PR_EXPIRED: _('This invoice has expired'),
+ PR_PAID: _('This invoice was already paid'),
+ PR_INFLIGHT: _('Payment in progress...'),
+ PR_ROUTING: _('Payment in progress...'),
+ PR_BROADCASTING: _('Payment in progress...') + ' (' + _('broadcasting') + ')',
+ PR_BROADCAST: _('Payment in progress...') + ' (' + _('broadcast successfully') + ')',
+ PR_UNCONFIRMED: _('Payment in progress...') + ' (' + _('waiting for confirmation') + ')',
+ PR_UNKNOWN: _('Invoice has unknown status'),
+ }[_status]
+
+ if status in [PR_UNPAID, PR_FAILED]:
+ x, self.userinfo = self.check_can_pay_amount(amount)
+ elif status == PR_PAID and self._paid_in_this_session:
+ self.userinfo = _('Paid!')
+ else:
+ self.userinfo = userinfo_for_invoice_status(status)
+
+ def determine_can_pay(self):
+ self.canPay = False
+ self.canSave = False
+
+ if self.invoiceType not in [QEInvoice.Type.LightningInvoice, QEInvoice.Type.OnchainInvoice]:
+ return
+
+ if not self.amountOverride.isEmpty:
+ amount = self.amountOverride
+ else:
+ amount = self.amount
+
+ self.canSave = not bool(self._wallet.wallet.get_invoice(self._effectiveInvoice.get_id()))
+
+ status = self.status
+
+ if amount.isEmpty and status == PR_UNPAID: # unspecified amount
+ return
+
+ if status in [PR_UNPAID, PR_FAILED]:
+ self.canPay, x = self.check_can_pay_amount(amount)
+
+ def check_can_pay_amount(self, amount: QEAmount) -> Tuple[bool, Optional[str]]:
+ assert self.status in [PR_UNPAID, PR_FAILED]
+ if self.invoiceType == QEInvoice.Type.LightningInvoice:
+ if self.get_max_spendable_lightning() * 1000 >= amount.msatsInt:
+ lnaddr = self._effectiveInvoice._lnaddr
+ if lnaddr.amount and amount.msatsInt < lnaddr.amount * COIN * 1000:
+ return False, _('Cannot pay less than the amount specified in the invoice')
+ else:
+ return True, None
+ elif self.address and self.get_max_spendable_onchain() > amount.satsInt:
+ return True, None
+ elif self.invoiceType == QEInvoice.Type.OnchainInvoice:
+ if (amount.isMax and self.get_max_spendable_onchain() > 0) or (self.get_max_spendable_onchain() >= amount.satsInt):
+ return True, None
+
+ return False, _('Insufficient balance')
+
+ @pyqtSlot()
+ def payLightningInvoice(self):
+ if not self.canPay:
+ raise Exception('can not pay invoice, canPay is false')
+
+ if self.invoiceType != QEInvoice.Type.LightningInvoice:
+ raise Exception('payLightningInvoice can only pay lightning invoices')
+
+ amount_msat = None
+ if self.amount.isEmpty:
+ if self.amountOverride.isEmpty:
+ raise Exception('can not pay 0 amount')
+ amount_msat = self.amountOverride.msatsInt
+
+ self._paid_in_this_session = True
+ self._wallet.pay_lightning_invoice(self._effectiveInvoice, amount_msat)
+
+ def get_max_spendable_onchain(self):
+ return self._wallet.wallet.get_spendable_balance_sat()
+
+ def get_max_spendable_lightning(self):
+ return self._wallet.wallet.lnworker.num_sats_can_send() if self._wallet.wallet.lnworker else 0
+
+ @pyqtSlot()
+ def updateMaxAmount(self):
+ if self._updating_max:
+ return
+
+ assert self.invoiceType == QEInvoice.Type.OnchainInvoice
+
+ # only single address invoice supported
+ invoice_address = self._effectiveInvoice.get_address()
+
+ self._updating_max = True
+
+ def calc_max(address):
+ try:
+ outputs = [PartialTxOutput(scriptpubkey=address_to_script(address), value='!')]
+ make_tx = lambda fee_policy, *, confirmed_only=False: self._wallet.wallet.make_unsigned_transaction(
+ coins=self._wallet.wallet.get_spendable_coins(None),
+ outputs=outputs,
+ fee_policy=fee_policy,
+ is_sweep=False)
+ amount, message = self._wallet.determine_max(mktx=make_tx)
+ if amount is None:
+ self._amountOverride.isMax = False
+ else:
+ self._amountOverride.satsInt = amount
+ if message:
+ self.maxAmountMessage.emit(message)
+ finally:
+ self._updating_max = False
+
+ threading.Thread(target=calc_max, args=(invoice_address,), daemon=True).start()
+
+
+class QEInvoiceParser(QEInvoice):
+ _logger = get_logger(__name__)
+
+ validationSuccess = pyqtSignal()
+ validationWarning = pyqtSignal([str, str], arguments=['code', 'message'])
+ validationError = pyqtSignal([str, str], arguments=['code', 'message'])
+
+ invoiceCreateError = pyqtSignal([str, str], arguments=['code', 'message'])
+
+ lnurlRetrieved = pyqtSignal()
+ lnurlError = pyqtSignal([str, str], arguments=['code', 'message'])
+
+ busyChanged = pyqtSignal()
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ self._pi = None # type: Optional[PaymentIdentifier]
+ self._lnurlData = None
+ self._busy = False
+
+ self.clear()
+
+ @pyqtSlot(object)
+ def fromResolvedPaymentIdentifier(self, resolved_pi: PaymentIdentifier) -> None:
+ self.clear()
+ self.amountOverride = QEAmount()
+ if resolved_pi:
+ assert not resolved_pi.need_resolve()
+ self.validateRecipient(resolved_pi)
+
+ @pyqtProperty('QVariantMap', notify=lnurlRetrieved)
+ def lnurlData(self):
+ return self._lnurlData
+
+ @pyqtProperty(bool, notify=lnurlRetrieved)
+ def isLnurlPay(self):
+ return self._lnurlData is not None
+
+ @pyqtProperty(bool, notify=busyChanged)
+ def busy(self):
+ return self._busy
+
+ @pyqtSlot()
+ def clear(self):
+ self.setInvoiceType(QEInvoice.Type.Invalid)
+ self._lnurlData = None
+ self.canSave = False
+ self.canPay = False
+ self.userinfo = ''
+ self.invoiceChanged.emit()
+
+ def setValidOnchainInvoice(self, invoice: Invoice):
+ self._logger.debug('setValidOnchainInvoice')
+ if invoice.is_lightning():
+ raise Exception('unexpected LN invoice')
+ self.set_effective_invoice(invoice)
+
+ def setValidLightningInvoice(self, invoice: Invoice):
+ self._logger.debug('setValidLightningInvoice')
+ if not invoice.is_lightning():
+ raise Exception('unexpected Onchain invoice')
+ self._key = invoice.get_id()
+ self.set_effective_invoice(invoice)
+
+ def setValidLNURLPayRequest(self):
+ self._logger.debug('setValidLNURLPayRequest')
+ self.setInvoiceType(QEInvoice.Type.LNURLPayRequest)
+ self._effectiveInvoice = None
+ self.invoiceChanged.emit()
+
+ def create_onchain_invoice(self, *, outputs, message, uri):
+ return self._wallet.wallet.create_invoice(
+ outputs=outputs,
+ message=message,
+ URI=uri,
+ )
+
+ def validateRecipient(self, pi: PaymentIdentifier):
+ if not pi:
+ self.setInvoiceType(QEInvoice.Type.Invalid)
+ return
+
+ self._pi = pi
+ if not self._pi.is_valid() or self._pi.type not in [
+ PaymentIdentifierType.SPK, PaymentIdentifierType.BIP21,
+ PaymentIdentifierType.BOLT11,
+ PaymentIdentifierType.LNADDR, PaymentIdentifierType.LNURLP,
+ PaymentIdentifierType.EMAILLIKE, PaymentIdentifierType.DOMAINLIKE,
+ PaymentIdentifierType.OPENALIAS,
+ ]:
+ self.validationError.emit('unknown', _('Unknown invoice'))
+ return
+
+ if self._pi.type == PaymentIdentifierType.SPK:
+ txo = TxOutput(scriptpubkey=self._pi.spk, value=0)
+ if not txo.address:
+ self.validationError.emit('unknown', _('Unknown invoice'))
+ return
+
+ self._update_from_payment_identifier()
+
+ def _update_from_payment_identifier(self):
+ assert not self._pi.need_resolve(), "Should have been resolved by QEPIResolver"
+
+ if self._pi.type in [
+ PaymentIdentifierType.LNURLP,
+ PaymentIdentifierType.LNADDR,
+ ]:
+ self.on_lnurl_pay(self._pi.lnurl_data)
+ return
+
+ if self._pi.is_available():
+ if self._pi.type in [PaymentIdentifierType.SPK, PaymentIdentifierType.OPENALIAS]:
+ outputs = [PartialTxOutput(scriptpubkey=self._pi.spk, value=0)]
+ invoice = self.create_onchain_invoice(outputs=outputs, message=None, uri=None)
+ self._logger.debug(repr(invoice))
+ self.setValidOnchainInvoice(invoice)
+ self.validationSuccess.emit()
+ return
+ elif self._pi.type == PaymentIdentifierType.BOLT11:
+ lninvoice = self._pi.bolt11
+ if not self._wallet.wallet.has_lightning() and not lninvoice.get_address():
+ self.validationError.emit('no_lightning',
+ _('Detected valid Lightning invoice, but Lightning not enabled for wallet and no fallback address found.'))
+ return
+ if self._wallet.wallet.lnworker and not self._wallet.wallet.lnworker.channels and not lninvoice.get_address():
+ self.validationWarning.emit('no_channels',
+ _('Detected valid Lightning invoice, but there are no open channels'))
+ self.setValidLightningInvoice(lninvoice)
+ self.validationSuccess.emit()
+ elif self._pi.type == PaymentIdentifierType.BIP21:
+ if self._wallet.wallet.has_lightning() and self._wallet.wallet.lnworker.channels and self._pi.bolt11:
+ lninvoice = self._pi.bolt11
+ self.setValidLightningInvoice(lninvoice)
+ self.validationSuccess.emit()
+ else:
+ self._validateRecipient_bip21_onchain(self._pi.bip21)
+
+ def _validateRecipient_bip21_onchain(self, bip21: Dict[str, Any]) -> None:
+ if 'address' not in bip21:
+ self._logger.debug('Neither LN invoice nor address in bip21 uri')
+ self.validationError.emit('unknown', _('Unknown invoice'))
+ return
+
+ amount = bip21.get('amount', 0)
+ outputs = [PartialTxOutput.from_address_and_value(bip21['address'], amount)]
+ self._logger.debug(outputs)
+ message = bip21.get('message', '')
+ invoice = self.create_onchain_invoice(outputs=outputs, message=message, uri=bip21)
+ self._logger.debug(repr(invoice))
+ self.setValidOnchainInvoice(invoice)
+ self.validationSuccess.emit()
+
+ def on_lnurl_pay(self, lnurldata: LNURL6Data):
+ assert isinstance(lnurldata, LNURL6Data)
+ self._logger.debug('on_lnurl')
+ self._logger.debug(f'{repr(lnurldata)}')
+
+ self._lnurlData = {
+ 'domain': urlparse(lnurldata.callback_url).netloc,
+ 'callback_url': lnurldata.callback_url,
+ 'min_sendable_sat': lnurldata.min_sendable_sat,
+ 'max_sendable_sat': lnurldata.max_sendable_sat,
+ 'metadata_plaintext': lnurldata.metadata_plaintext,
+ 'comment_allowed': lnurldata.comment_allowed,
+ }
+ self.setValidLNURLPayRequest()
+ self.lnurlRetrieved.emit()
+
+ @pyqtSlot()
+ @pyqtSlot(str)
+ def lnurlGetInvoice(self, comment=None):
+ assert self._lnurlData
+ assert self._pi.need_finalize()
+ assert self.invoiceType == QEInvoice.Type.LNURLPayRequest
+ self._logger.debug(f'{repr(self._lnurlData)}')
+
+ amount = self.amountOverride.satsInt
+
+ if self._lnurlData['comment_allowed'] == 0:
+ comment = None
+
+ def on_finished(pi):
+ self._busy = False
+ self.busyChanged.emit()
+
+ if pi.is_error():
+ if pi.state == PaymentIdentifierState.INVALID_AMOUNT:
+ self.lnurlError.emit('amount', pi.get_error())
+ else:
+ self.lnurlError.emit('lnurl', pi.get_error())
+ else:
+ self.on_lnurl_invoice(self.amountOverride.satsInt, pi.bolt11)
+
+ self._busy = True
+ self.busyChanged.emit()
+
+ self._pi.finalize(amount_sat=amount, comment=comment, on_finished=on_finished)
+
+ def on_lnurl_invoice(self, orig_amount, invoice):
+ self._logger.debug('on_lnurl_invoice')
+ self._logger.debug(f'{repr(invoice)}')
+
+ # assure no shenanigans with the bolt11 invoice we get back
+ if orig_amount * 1000 != invoice.amount_msat: # TODO msat precision can cause trouble here
+ raise Exception('Unexpected amount in invoice, differs from lnurl-pay specified amount')
+
+ self.amountOverride = QEAmount()
+ self.validateRecipient(
+ PaymentIdentifier(self._wallet.wallet, invoice.lightning_invoice)
+ )
+
+ @pyqtSlot(result=bool)
+ def saveInvoice(self) -> bool:
+ if not self._effectiveInvoice:
+ return False
+ if self.isSaved:
+ return False
+
+ try:
+ if not self._effectiveInvoice.amount_msat and not self.amountOverride.isEmpty:
+ if self.invoiceType == QEInvoice.Type.OnchainInvoice and self.amountOverride.isMax:
+ self._effectiveInvoice.set_amount_msat('!')
+ else:
+ self._effectiveInvoice.set_amount_msat(self.amountOverride.satsInt * 1000)
+ except InvoiceError as e:
+ self.invoiceCreateError.emit('validation', str(e))
+ return False
+
+ self.canSave = False
+
+ self._wallet.wallet.save_invoice(self._effectiveInvoice)
+ self._key = self._effectiveInvoice.get_id()
+ self._wallet.invoiceModel.addInvoice(self._key)
+ self.invoiceSaved.emit(self._key)
+
+ return True
diff --git a/electrum/gui/qml/qeinvoicelistmodel.py b/electrum/gui/qml/qeinvoicelistmodel.py
new file mode 100644
index 000000000000..551afb19fa84
--- /dev/null
+++ b/electrum/gui/qml/qeinvoicelistmodel.py
@@ -0,0 +1,255 @@
+from abc import abstractmethod
+from typing import TYPE_CHECKING, List, Dict, Any
+
+from PyQt6.QtCore import pyqtSlot, QTimer
+from PyQt6.QtCore import Qt, QAbstractListModel, QModelIndex
+
+from electrum.logging import get_logger
+from electrum.util import Satoshis, format_time
+from electrum.invoices import BaseInvoice, PR_EXPIRED, LN_EXPIRY_NEVER, Invoice, Request, PR_PAID
+
+from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
+
+from .util import status_update_timer_interval
+from .qetypes import QEAmount
+
+if TYPE_CHECKING:
+ from electrum.wallet import Abstract_Wallet
+
+
+class QEAbstractInvoiceListModel(QAbstractListModel):
+ _logger = get_logger(__name__)
+
+ # define listmodel rolemap
+ _ROLE_NAMES=('key', 'is_lightning', 'timestamp', 'date', 'message', 'amount',
+ 'status', 'status_str', 'address', 'expiry', 'type', 'onchain_fallback',
+ 'lightning_invoice')
+ _ROLE_KEYS = range(Qt.ItemDataRole.UserRole, Qt.ItemDataRole.UserRole + len(_ROLE_NAMES))
+ _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES]))
+ _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS))
+
+ def __init__(self, wallet: 'Abstract_Wallet', parent=None):
+ super().__init__(parent)
+ self.wallet = wallet
+ self._invoices = []
+
+ self._timer = QTimer(self)
+ self._timer.setSingleShot(True)
+ self._timer.timeout.connect(self.updateStatusStrings)
+
+ try:
+ self.initModel()
+ except Exception as e:
+ self._logger.error(f'{repr(e)}')
+ raise e
+
+ def rowCount(self, index):
+ return len(self._invoices)
+
+ def roleNames(self):
+ return self._ROLE_MAP
+
+ def data(self, index, role):
+ invoice = self._invoices[index.row()]
+ role_index = role - Qt.ItemDataRole.UserRole
+ value = invoice[self._ROLE_NAMES[role_index]]
+
+ if isinstance(value, (bool, list, int, str, QEAmount)) or value is None:
+ return value
+ if isinstance(value, Satoshis):
+ return value.value
+ return str(value)
+
+ def clear(self):
+ self.beginResetModel()
+ self._invoices = []
+ self.endResetModel()
+
+ @pyqtSlot()
+ def initModel(self):
+ invoices = []
+ for invoice in self.get_invoice_list():
+ item = self.invoice_to_model(invoice)
+ invoices.append(item)
+
+ self.clear()
+ self.beginInsertRows(QModelIndex(), 0, len(invoices) - 1)
+ self._invoices = invoices
+ self.endInsertRows()
+
+ self.set_status_timer()
+
+ def add_invoice(self, invoice: BaseInvoice):
+ # skip if already in list
+ key = invoice.get_id()
+ for x in self._invoices:
+ if x['key'] == key:
+ return
+
+ item = self.invoice_to_model(invoice)
+ self._logger.debug(str(item))
+
+ self.beginInsertRows(QModelIndex(), 0, 0)
+ self._invoices.insert(0, item)
+ self.endInsertRows()
+
+ self.set_status_timer()
+
+ @pyqtSlot(str)
+ def addInvoice(self, key):
+ self.add_invoice(self.get_invoice_for_key(key))
+
+ def delete_invoice(self, key: str):
+ for i, invoice in enumerate(self._invoices):
+ if invoice['key'] == key:
+ self.beginRemoveRows(QModelIndex(), i, i)
+ self._invoices.pop(i)
+ self.endRemoveRows()
+ break
+ self.set_status_timer()
+
+ def get_model_invoice(self, key: str):
+ for invoice in self._invoices:
+ if invoice['key'] == key:
+ return invoice
+ return None
+
+ @pyqtSlot(str, int)
+ def updateInvoice(self, key, status):
+ self._logger.debug(f'updating invoice for {key} to {status}')
+ for i, item in enumerate(self._invoices):
+ if item['key'] == key:
+ invoice = self.get_invoice_for_key(key)
+ item['status'] = status
+ item['status_str'] = invoice.get_status_str(status)
+ index = self.index(i, 0)
+ self.dataChanged.emit(index, index, [self._ROLE_RMAP['status'], self._ROLE_RMAP['status_str']])
+ return
+
+ def invoice_to_model(self, invoice: BaseInvoice):
+ item = self.get_invoice_as_dict(invoice)
+ item['key'] = invoice.get_id()
+ item['is_lightning'] = invoice.is_lightning()
+ if invoice.is_lightning() and 'address' not in item:
+ item['address'] = ''
+ item['date'] = format_time(item['timestamp'])
+ item['amount'] = QEAmount(from_invoice=invoice)
+ item['onchain_fallback'] = invoice.is_lightning() and bool(invoice.get_address())
+
+ return item
+
+ def set_status_timer(self):
+ nearest_interval = LN_EXPIRY_NEVER
+ for invoice in self._invoices:
+ if invoice['status'] != PR_EXPIRED:
+ if invoice['expiry'] > 0 and invoice['expiry'] != LN_EXPIRY_NEVER:
+ interval = status_update_timer_interval(invoice['timestamp'] + invoice['expiry'])
+ if interval > 0:
+ nearest_interval = nearest_interval if nearest_interval < interval else interval
+
+ if nearest_interval != LN_EXPIRY_NEVER:
+ self._timer.setInterval(nearest_interval) # msec
+ self._timer.start()
+
+ @pyqtSlot()
+ def updateStatusStrings(self):
+ for i, item in enumerate(self._invoices):
+ invoice = self.get_invoice_for_key(item['key'])
+ if invoice is None: # invoice might be removed from the backend
+ self._logger.debug(f'invoice {item["key"]} not found')
+ continue
+ item['status'] = self.wallet.get_invoice_status(invoice)
+ item['status_str'] = invoice.get_status_str(item['status'])
+ index = self.index(i, 0)
+ self.dataChanged.emit(index, index, [self._ROLE_RMAP['status'], self._ROLE_RMAP['status_str']])
+
+ self.set_status_timer()
+
+ @abstractmethod
+ def get_invoice_for_key(self, key: str):
+ raise Exception('provide impl')
+
+ @abstractmethod
+ def get_invoice_list(self) -> List[BaseInvoice]:
+ raise Exception('provide impl')
+
+ @abstractmethod
+ def get_invoice_as_dict(self, invoice: BaseInvoice) -> Dict[str, Any]:
+ raise Exception('provide impl')
+
+
+class QEInvoiceListModel(QEAbstractInvoiceListModel, QtEventListener):
+ def __init__(self, wallet, parent=None):
+ super().__init__(wallet, parent)
+ self.register_callbacks()
+ self.destroyed.connect(lambda: self.on_destroy())
+
+ _logger = get_logger(__name__)
+
+ def on_destroy(self):
+ self.unregister_callbacks()
+
+ @qt_event_listener
+ def on_event_invoice_status(self, wallet, key, status):
+ if wallet == self.wallet:
+ self._logger.debug(f'invoice status update for key {key} to {status}')
+ self.updateInvoice(key, status)
+
+ def invoice_to_model(self, invoice: BaseInvoice):
+ item = super().invoice_to_model(invoice)
+ item['type'] = 'invoice'
+
+ return item
+
+ def get_invoice_list(self):
+ lst = self.wallet.get_unpaid_invoices()
+ lst.reverse()
+ return lst
+
+ def get_invoice_for_key(self, key: str):
+ return self.wallet.get_invoice(key)
+
+ def get_invoice_as_dict(self, invoice: Invoice):
+ return self.wallet.export_invoice(invoice)
+
+
+class QERequestListModel(QEAbstractInvoiceListModel, QtEventListener):
+ def __init__(self, wallet, parent=None):
+ super().__init__(wallet, parent)
+ self.register_callbacks()
+ self.destroyed.connect(lambda: self.on_destroy())
+
+ _logger = get_logger(__name__)
+
+ def on_destroy(self):
+ self.unregister_callbacks()
+
+ @qt_event_listener
+ def on_event_request_status(self, wallet, key, status):
+ if wallet == self.wallet:
+ self._logger.debug(f'request status update for key {key} to {status}')
+ self.updateRequest(key, status)
+
+ def invoice_to_model(self, invoice: BaseInvoice):
+ item = super().invoice_to_model(invoice)
+ item['type'] = 'request'
+
+ return item
+
+ def get_invoice_list(self):
+ lst = self.wallet.get_unpaid_requests()
+ lst.reverse()
+ return lst
+
+ def get_invoice_for_key(self, key: str):
+ return self.wallet.get_request(key)
+
+ def get_invoice_as_dict(self, invoice: Request):
+ return self.wallet.export_request(invoice)
+
+ @pyqtSlot(str, int)
+ def updateRequest(self, key, status):
+ if status == PR_PAID:
+ self.delete_invoice(key)
+ else:
+ self.updateInvoice(key, status)
diff --git a/electrum/gui/qml/qelnpaymentdetails.py b/electrum/gui/qml/qelnpaymentdetails.py
new file mode 100644
index 000000000000..94e96138cb73
--- /dev/null
+++ b/electrum/gui/qml/qelnpaymentdetails.py
@@ -0,0 +1,112 @@
+from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QVariant
+
+from electrum.logging import get_logger
+from electrum.util import bfh, format_time
+
+from .qetypes import QEAmount
+from .qewallet import QEWallet
+
+
+class QELnPaymentDetails(QObject):
+ _logger = get_logger(__name__)
+
+ detailsChanged = pyqtSignal()
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ self._wallet = None
+ self._key = None
+ self._label = ''
+ self._date = None
+ self._timestamp = 0
+ self._fee = QEAmount()
+ self._amount = QEAmount()
+ self._status = ''
+ self._phash = ''
+ self._preimage = ''
+
+ walletChanged = pyqtSignal()
+ @pyqtProperty(QVariant, notify=walletChanged)
+ def wallet(self) -> QEWallet:
+ return self._wallet
+
+ @wallet.setter
+ def wallet(self, wallet: QEWallet):
+ assert wallet is None or isinstance(wallet, QEWallet)
+ if self._wallet != wallet:
+ self._wallet = wallet
+ self.walletChanged.emit()
+
+ keyChanged = pyqtSignal()
+ @pyqtProperty(str, notify=keyChanged)
+ def key(self):
+ return self._key
+
+ @key.setter
+ def key(self, key: str):
+ if self._key != key:
+ self._logger.debug(f'key set -> {key}')
+ self._key = key
+ self.keyChanged.emit()
+ self.update()
+
+ labelChanged = pyqtSignal()
+ @pyqtProperty(str, notify=labelChanged)
+ def label(self):
+ return self._label
+
+ @pyqtSlot(str)
+ def setLabel(self, label: str):
+ if label != self._label:
+ self._wallet.wallet.set_label(self._key, label)
+ self._label = label
+ self.labelChanged.emit()
+
+ @pyqtProperty(str, notify=detailsChanged)
+ def status(self):
+ return self._status
+
+ @pyqtProperty(str, notify=detailsChanged)
+ def date(self):
+ return self._date
+
+ @pyqtProperty(int, notify=detailsChanged)
+ def timestamp(self):
+ return self._timestamp
+
+ @pyqtProperty(str, notify=detailsChanged)
+ def paymentHash(self):
+ return self._phash
+
+ @pyqtProperty(str, notify=detailsChanged)
+ def preimage(self):
+ return self._preimage
+
+ @pyqtProperty(QEAmount, notify=detailsChanged)
+ def amount(self):
+ return self._amount
+
+ @pyqtProperty(QEAmount, notify=detailsChanged)
+ def fee(self):
+ return self._fee
+
+ def update(self):
+ if self._wallet is None:
+ self._logger.error('wallet undefined')
+ return
+
+ # TODO this is horribly inefficient. need a payment getter/query method
+ tx = self._wallet.wallet.lnworker.get_lightning_history()[self._key]
+ self._logger.debug(str(tx))
+
+ self._fee.msatsInt = 0 if not tx.fee_msat else int(tx.fee_msat)
+ self._amount.msatsInt = int(tx.amount_msat)
+ self._label = tx.label
+ self._date = format_time(tx.timestamp)
+ self._timestamp = tx.timestamp
+ self._status = 'settled' # TODO: other states? get_lightning_history is deciding the filter for us :(
+ self._phash = tx.payment_hash
+ self._preimage = tx.preimage
+
+ self.detailsChanged.emit()
diff --git a/electrum/gui/qml/qemodelfilter.py b/electrum/gui/qml/qemodelfilter.py
new file mode 100644
index 000000000000..e6bbcdaadd55
--- /dev/null
+++ b/electrum/gui/qml/qemodelfilter.py
@@ -0,0 +1,33 @@
+from PyQt6.QtCore import pyqtSignal, pyqtProperty, QSortFilterProxyModel, QModelIndex, pyqtSlot
+
+from electrum.logging import get_logger
+
+
+class QEFilterProxyModel(QSortFilterProxyModel):
+ _logger = get_logger(__name__)
+
+ def __init__(self, parent_model, parent=None):
+ super().__init__(parent)
+ self._filter_value = None
+ self.setSourceModel(parent_model)
+
+ countChanged = pyqtSignal()
+ @pyqtProperty(int, notify=countChanged)
+ def count(self):
+ return self.rowCount(QModelIndex())
+
+ def isCustomFilter(self):
+ return self._filter_value is not None
+
+ @pyqtSlot(str)
+ def setFilterValue(self, filter_value):
+ self._filter_value = filter_value
+ self.invalidate()
+
+ def filterAcceptsRow(self, s_row, s_parent):
+ if not self.isCustomFilter:
+ return super().filterAcceptsRow(s_row, s_parent)
+
+ parent_model = self.sourceModel()
+ d = parent_model.data(parent_model.index(s_row, 0, s_parent), self.filterRole())
+ return True if self._filter_value is None else d == self._filter_value
diff --git a/electrum/gui/qml/qenetwork.py b/electrum/gui/qml/qenetwork.py
new file mode 100644
index 000000000000..5130bf4f9cc2
--- /dev/null
+++ b/electrum/gui/qml/qenetwork.py
@@ -0,0 +1,314 @@
+from typing import TYPE_CHECKING
+
+from PyQt6.QtCore import pyqtProperty, pyqtSignal, QObject, pyqtSlot
+
+from electrum.logging import get_logger
+from electrum import constants
+from electrum.network import ProxySettings
+from electrum.interface import ServerAddr
+from electrum.fee_policy import FEERATE_DEFAULT_RELAY
+from electrum.util import event_listener
+
+from electrum.gui.common_qt.util import QtEventListener
+
+from .qeconfig import QEConfig
+from .qeserverlistmodel import QEServerListModel
+
+if TYPE_CHECKING:
+ from electrum.network import Network
+
+
+class QENetwork(QObject, QtEventListener):
+ _logger = get_logger(__name__)
+
+ networkUpdated = pyqtSignal()
+ blockchainUpdated = pyqtSignal()
+ heightChanged = pyqtSignal([int], arguments=['height']) # local blockchain height
+ serverHeightChanged = pyqtSignal([int], arguments=['height'])
+ proxySet = pyqtSignal()
+ proxyChanged = pyqtSignal()
+ torProbeFinished = pyqtSignal([str, int], arguments=['host', 'port'])
+ statusChanged = pyqtSignal()
+ feeHistogramUpdated = pyqtSignal()
+ chaintipsChanged = pyqtSignal()
+ isLaggingChanged = pyqtSignal()
+ gossipUpdated = pyqtSignal()
+
+ # shared signal for static properties
+ dataChanged = pyqtSignal()
+
+ _height = 0
+ _server = ""
+ _is_connected = False
+ _server_status = ""
+ _network_status = ""
+ _chaintips = 1
+ _islagging = False
+ _fee_histogram = []
+ _gossipPeers = 0
+ _gossipUnknownChannels = 0
+ _gossipDbNodes = 0
+ _gossipDbChannels = 0
+ _gossipDbPolicies = 0
+
+ def __init__(self, network: 'Network', parent=None):
+ super().__init__(parent)
+ assert network, "--offline is not yet implemented for this GUI" # TODO
+ self.network = network
+ self._serverListModel = None
+ self._height = network.get_local_height() # init here, update event can take a while
+ self._server_height = network.get_server_height() # init here, update event can take a while
+ self.register_callbacks()
+ self.destroyed.connect(lambda: self.on_destroy())
+
+ QEConfig.instance.useGossipChanged.connect(self.on_gossip_setting_changed)
+
+ def on_destroy(self):
+ self.unregister_callbacks()
+
+ @event_listener
+ def on_event_network_updated(self, *args):
+ self.networkUpdated.emit()
+ self._update_status()
+
+ @event_listener
+ def on_event_blockchain_updated(self):
+ if self._height != self.network.get_local_height():
+ self._height = self.network.get_local_height()
+ self._logger.debug('new height: %d' % self._height)
+ self.heightChanged.emit(self._height)
+ self.blockchainUpdated.emit()
+
+ @event_listener
+ def on_event_default_server_changed(self, *args):
+ self._update_status()
+
+ @event_listener
+ def on_event_proxy_set(self, *args):
+ self._logger.debug('proxy set')
+ self.proxySet.emit()
+ self.proxyTorChanged.emit()
+
+ @event_listener
+ def on_event_tor_probed(self, *args):
+ self.proxyTorChanged.emit()
+
+ def _update_status(self):
+ server = str(self.network.get_parameters().server)
+ if self._server != server:
+ self._server = server
+ self.statusChanged.emit()
+ network_status = self.network.get_status()
+ if self._network_status != network_status:
+ self._logger.debug('network_status updated: %s' % network_status)
+ self._network_status = network_status
+ self.statusChanged.emit()
+ is_connected = self.network.is_connected()
+ if self._is_connected != is_connected:
+ self._is_connected = is_connected
+ self.statusChanged.emit()
+ server_status = self.network.get_connection_status_for_GUI()
+ if self._server_status != server_status:
+ self._logger.debug('server_status updated: %s' % server_status)
+ self._server_status = server_status
+ self.statusChanged.emit()
+ server_height = self.network.get_server_height()
+ if self._server_height != server_height:
+ self._logger.debug(f'server_height updated: {server_height}')
+ self._server_height = server_height
+ self.serverHeightChanged.emit(server_height)
+ chains = len(self.network.get_blockchains())
+ if chains != self._chaintips:
+ self._logger.debug('chain tips # changed: %d', chains)
+ self._chaintips = chains
+ self.chaintipsChanged.emit()
+ server_lag = self.network.get_local_height() - self.network.get_server_height()
+ if self._islagging ^ (server_lag > 1):
+ self._logger.debug('lagging changed: %s', str(server_lag > 1))
+ self._islagging = server_lag > 1
+ self.isLaggingChanged.emit()
+
+ @event_listener
+ def on_event_status(self, *args):
+ self._update_status()
+
+ @event_listener
+ def on_event_fee_histogram(self, histogram):
+ self._logger.debug(f'fee histogram updated')
+ self.update_histogram(histogram)
+
+ def update_histogram(self, histogram):
+ capped_histogram, bytes_current = histogram.get_capped_data()
+ # add clamping attributes for the GUI
+ self._fee_histogram = {
+ 'histogram': capped_histogram,
+ 'total': bytes_current,
+ 'min_fee': capped_histogram[-1][0] if capped_histogram else FEERATE_DEFAULT_RELAY/1000,
+ 'max_fee': capped_histogram[0][0] if capped_histogram else FEERATE_DEFAULT_RELAY/1000
+ }
+ self.feeHistogramUpdated.emit()
+
+ @event_listener
+ def on_event_channel_db(self, num_nodes, num_channels, num_policies):
+ changed = False
+ if self._gossipDbNodes != num_nodes:
+ self._gossipDbNodes = num_nodes
+ changed = True
+ if self._gossipDbChannels != num_channels:
+ self._gossipDbChannels = num_channels
+ changed = True
+ if self._gossipDbPolicies != num_policies:
+ self._gossipDbPolicies = num_policies
+ changed = True
+ if changed:
+ self._logger.debug(f'channel_db: {num_nodes} nodes, {num_channels} channels, {num_policies} policies')
+ self.gossipUpdated.emit()
+
+ @event_listener
+ def on_event_gossip_peers(self, num_peers):
+ self._logger.debug(f'gossip peers {num_peers}')
+ self._gossipPeers = num_peers
+ self.gossipUpdated.emit()
+
+ @event_listener
+ def on_event_unknown_channels(self, unknown):
+ if unknown == 0 and self._gossipUnknownChannels == 0: # TODO: backend sends a lot of unknown=0 events
+ return
+ self._logger.debug(f'unknown channels {unknown}')
+ self._gossipUnknownChannels = unknown
+ self.gossipUpdated.emit()
+
+ def on_gossip_setting_changed(self):
+ if not self.network:
+ return
+ if QEConfig.instance.useGossip:
+ self.network.start_gossip()
+ else:
+ self.network.run_from_another_thread(self.network.stop_gossip())
+
+ @pyqtProperty(int, notify=heightChanged)
+ def height(self): # local blockchain height
+ return self._height
+
+ @pyqtProperty(int, notify=serverHeightChanged)
+ def serverHeight(self):
+ return self._server_height
+
+ autoConnectChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=autoConnectChanged)
+ def autoConnect(self):
+ return self.network.config.NETWORK_AUTO_CONNECT
+
+ # auto_connect is actually a tri-state, expose the undefined case
+ @pyqtProperty(bool, notify=autoConnectChanged)
+ def autoConnectDefined(self):
+ return self.network.config.cv.NETWORK_AUTO_CONNECT.is_set()
+
+ @pyqtProperty(str, notify=statusChanged)
+ def server(self):
+ return self._server
+
+ @pyqtSlot(str, result=bool)
+ def isValidServerAddress(self, server: str) -> bool:
+ return ServerAddr.from_str_with_inference(server) is not None
+
+ @pyqtSlot(str, bool, bool)
+ def setServerParameters(self, server_str: str, auto_connect: bool, one_server: bool):
+ net_params = self.network.get_parameters()
+ server = ServerAddr.from_str_with_inference(server_str)
+ if server == net_params.server and auto_connect == net_params.auto_connect and one_server == net_params.oneserver:
+ return
+ if server != net_params.server:
+ if server is None:
+ if not auto_connect:
+ return
+ server = net_params.server
+ self.statusChanged.emit()
+ if auto_connect != net_params.auto_connect:
+ self.network.config.NETWORK_AUTO_CONNECT = auto_connect
+ self.autoConnectChanged.emit()
+ net_params = net_params._replace(server=server, auto_connect=auto_connect, oneserver=one_server)
+ self.network.run_from_another_thread(self.network.set_parameters(net_params))
+
+ @pyqtProperty(str, notify=statusChanged)
+ def serverWithStatus(self):
+ server = self._server
+ if not self.network.is_connected(): # connecting or disconnected
+ return f'{server} (connecting...)'
+ return server
+
+ @pyqtProperty(str, notify=statusChanged)
+ def status(self):
+ return self._network_status
+
+ @pyqtProperty(str, notify=statusChanged)
+ def serverStatus(self):
+ return self.network.get_connection_status_for_GUI()
+
+ @pyqtProperty(bool, notify=statusChanged)
+ def isConnected(self):
+ return self._is_connected
+
+ @pyqtProperty(int, notify=chaintipsChanged)
+ def chaintips(self):
+ return self._chaintips
+
+ @pyqtProperty(bool, notify=isLaggingChanged)
+ def isLagging(self):
+ return self._islagging
+
+ @pyqtProperty(bool, notify=dataChanged)
+ def isTestNet(self):
+ return constants.net.TESTNET
+
+ @pyqtProperty(str, notify=dataChanged)
+ def networkName(self):
+ return constants.net.__name__.replace('Bitcoin', '')
+
+ @pyqtProperty('QVariantMap', notify=proxyChanged)
+ def proxy(self):
+ net_params = self.network.get_parameters()
+ proxy = net_params.proxy
+ return proxy.to_dict()
+
+ @proxy.setter
+ def proxy(self, proxy_dict):
+ net_params = self.network.get_parameters()
+ proxy = ProxySettings.from_dict(proxy_dict)
+ net_params = net_params._replace(proxy=proxy)
+ self.network.run_from_another_thread(self.network.set_parameters(net_params))
+ self.proxyChanged.emit()
+
+ proxyTorChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=proxyTorChanged)
+ def isProxyTor(self):
+ return bool(self.network.is_proxy_tor)
+
+ @pyqtProperty(bool, notify=statusChanged)
+ def oneServer(self):
+ return self.network.oneserver
+
+ @pyqtProperty('QVariant', notify=feeHistogramUpdated)
+ def feeHistogram(self):
+ return self._fee_histogram
+
+ @pyqtProperty('QVariantMap', notify=gossipUpdated)
+ def gossipInfo(self):
+ return {
+ 'peers': self._gossipPeers,
+ 'unknown_channels': self._gossipUnknownChannels,
+ 'db_nodes': self._gossipDbNodes,
+ 'db_channels': self._gossipDbChannels,
+ 'db_policies': self._gossipDbPolicies
+ }
+
+ serverListModelChanged = pyqtSignal()
+ @pyqtProperty(QEServerListModel, notify=serverListModelChanged)
+ def serverListModel(self):
+ if self._serverListModel is None:
+ self._serverListModel = QEServerListModel(self.network)
+ return self._serverListModel
+
+ @pyqtSlot()
+ def probeTor(self):
+ ProxySettings.probe_tor(self.torProbeFinished.emit) # via signal
diff --git a/electrum/gui/qml/qepiresolver.py b/electrum/gui/qml/qepiresolver.py
new file mode 100644
index 000000000000..f2c5b916d706
--- /dev/null
+++ b/electrum/gui/qml/qepiresolver.py
@@ -0,0 +1,99 @@
+from enum import IntEnum
+from typing import Optional
+
+from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QTimer, QVariant
+
+from electrum.logging import get_logger
+from electrum.i18n import _
+from electrum.payment_identifier import PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType
+
+from .qewallet import QEWallet
+
+
+class QEPIResolver(QObject):
+ """Intended to handle a user input Payment Identifier (PI), resolve it if necessary, then
+ allow to distinguish between a Request/voucher/lnurlw and an Invoice (e.g. b11 or lnurlp)."""
+ _logger = get_logger(__name__)
+
+ busyChanged = pyqtSignal()
+ resolveError = pyqtSignal([str, str], arguments=['code', 'message'])
+ invoiceResolved = pyqtSignal([object], arguments=['pi'])
+ requestResolved = pyqtSignal([object], arguments=['pi'])
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ self._wallet = None # type: Optional[QEWallet]
+ self._recipient = None
+ self._pi = None
+ self._busy = False
+
+ self.clear()
+
+ recipientChanged = pyqtSignal()
+ @pyqtProperty(str, notify=recipientChanged)
+ def recipient(self) -> Optional[str]:
+ return self._recipient
+
+ @recipient.setter
+ def recipient(self, recipient: str) -> None:
+ self.clear()
+ if not recipient:
+ return
+ self._recipient = recipient
+ self.recipientChanged.emit()
+ self._pi = PaymentIdentifier(self._wallet.wallet, recipient)
+ if self._pi.need_resolve():
+ self.resolve_pi()
+ else:
+ # assuming if the PI is an invoice if it doesn't need resolving
+ # as there are no request types that do not need resolving currently
+ self.invoiceResolved.emit(self._pi)
+
+ walletChanged = pyqtSignal()
+ @pyqtProperty(QVariant, notify=walletChanged)
+ def wallet(self) -> Optional[QEWallet]:
+ return self._wallet
+
+ @wallet.setter
+ def wallet(self, wallet: QEWallet) -> None:
+ assert wallet is None or isinstance(wallet, QEWallet)
+ self._wallet = wallet
+
+ @pyqtProperty(bool, notify=busyChanged)
+ def busy(self):
+ return self._busy
+
+ def resolve_pi(self) -> None:
+ assert self._pi is not None
+ assert self._pi.need_resolve()
+
+ def on_finished(pi: PaymentIdentifier):
+ self._busy = False
+ self.busyChanged.emit()
+
+ if pi.is_error():
+ if pi.type in [PaymentIdentifierType.EMAILLIKE, PaymentIdentifierType.DOMAINLIKE]:
+ msg = _('Could not resolve address')
+ elif pi.type == PaymentIdentifierType.LNURL:
+ msg = _('Could not resolve LNURL') + "\n\n" + pi.get_error()
+ else:
+ msg = _('Could not resolve')
+ self.resolveError.emit('resolve', msg)
+ else:
+ if pi.type == PaymentIdentifierType.LNURLW:
+ self.requestResolved.emit(pi)
+ else:
+ self.invoiceResolved.emit(pi)
+
+ self._busy = True
+ self.busyChanged.emit()
+
+ self._pi.resolve(on_finished=on_finished)
+
+ def clear(self) -> None:
+ self._recipient = None
+ self._pi = None
+ self._busy = False
+ self.busyChanged.emit()
+ self.recipientChanged.emit()
diff --git a/electrum/gui/qml/qeqr.py b/electrum/gui/qml/qeqr.py
new file mode 100644
index 000000000000..f7c20af59fc6
--- /dev/null
+++ b/electrum/gui/qml/qeqr.py
@@ -0,0 +1,209 @@
+import asyncio
+import qrcode
+from qrcode.exceptions import DataOverflowError
+
+import math
+import urllib
+
+from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QRect
+from PyQt6.QtGui import QImage, QColor
+from PyQt6.QtQuick import QQuickImageProvider
+try:
+ from PyQt6.QtMultimedia import QVideoSink
+except ImportError:
+ # stub QVideoSink when not found, as it's not essential on android
+ # and requires many dependencies when unit testing.
+ # Note: missing QtMultimedia will lead to errors when using QR scanner on desktop
+ from PyQt6.QtCore import QObject as QVideoSink
+
+from electrum.logging import get_logger
+from electrum.qrreader import get_qr_reader
+from electrum.i18n import _
+from electrum.util import profiler, get_asyncio_loop
+from electrum.gui.common_qt.util import draw_qr
+
+
+class QEQRParser(QObject):
+ _logger = get_logger(__name__)
+
+ busyChanged = pyqtSignal()
+ dataChanged = pyqtSignal()
+ sizeChanged = pyqtSignal()
+ videoSinkChanged = pyqtSignal()
+
+ def __init__(self, text=None, parent=None):
+ super().__init__(parent)
+
+ self._busy = False
+ self._data = None
+ self._video_sink = None
+
+ self._text = text
+ self.qrreader = get_qr_reader()
+ if not self.qrreader:
+ raise Exception(_("The platform QR detection library is not available."))
+
+ @pyqtProperty(QVideoSink, notify=videoSinkChanged)
+ def videoSink(self):
+ return self._video_sink
+
+ @videoSink.setter
+ def videoSink(self, sink: QVideoSink):
+ if self._video_sink != sink:
+ self._video_sink = sink
+ self._video_sink.videoFrameChanged.connect(self.onVideoFrame)
+
+ def onVideoFrame(self, videoframe):
+ if self._busy or self._data:
+ return
+
+ self._busy = True
+ self.busyChanged.emit()
+
+ if not videoframe.isValid():
+ self._logger.debug('invalid frame')
+ return
+
+ async def co_parse_qr(frame):
+ image = frame.toImage()
+ self._parseQR(image)
+
+ asyncio.run_coroutine_threadsafe(co_parse_qr(videoframe), get_asyncio_loop())
+
+ def _parseQR(self, image: QImage):
+ self._size = min(image.width(), image.height())
+ self.sizeChanged.emit()
+ img_crop_rect = self._get_crop(image, self._size)
+ frame_cropped = image.copy(img_crop_rect)
+
+ # Convert to Y800 / GREY FourCC (single 8-bit channel)
+ frame_y800 = frame_cropped.convertToFormat(QImage.Format.Format_Grayscale8)
+ self.frame_id = 0
+ # Read the QR codes from the frame
+ self.qrreader_res = self.qrreader.read_qr_code(
+ frame_y800.constBits().__int__(),
+ frame_y800.sizeInBytes(),
+ frame_y800.bytesPerLine(),
+ frame_y800.width(),
+ frame_y800.height(),
+ self.frame_id
+ )
+
+ if len(self.qrreader_res) > 0:
+ result = self.qrreader_res[0]
+ self._data = result
+ self.dataChanged.emit()
+
+ self._busy = False
+ self.busyChanged.emit()
+
+ def _get_crop(self, image: QImage, scan_size: int) -> QRect:
+ """Returns a QRect that is scan_size x scan_size in the middle of the resolution"""
+ scan_pos_x = (image.width() - scan_size) // 2
+ scan_pos_y = (image.height() - scan_size) // 2
+ return QRect(scan_pos_x, scan_pos_y, scan_size, scan_size)
+
+ @pyqtProperty(bool, notify=busyChanged)
+ def busy(self):
+ return self._busy
+
+ @pyqtProperty(int, notify=sizeChanged)
+ def size(self):
+ return self._size
+
+ @pyqtProperty(str, notify=dataChanged)
+ def data(self):
+ if not self._data:
+ return ''
+ return self._data.data
+
+ @pyqtSlot()
+ def reset(self):
+ self._data = None
+ self.dataChanged.emit()
+
+
+class QEQRImageProvider(QQuickImageProvider):
+ MAX_QR_PIXELSIZE = 400
+ ERROR_CORRECT_LEVEL = qrcode.constants.ERROR_CORRECT_M
+ # ^ note: this is higher than for desktop. but on desktop we don't put a logo in the middle.
+ QR_BORDER = 2
+
+ def __init__(self, max_size, parent=None):
+ super().__init__(QQuickImageProvider.ImageType.Image)
+ self._max_size = max_size
+ self.qimg = None
+
+ _logger = get_logger(__name__)
+
+ @profiler
+ def requestImage(self, qstr, size):
+ # Qt does a urldecode before passing the string here
+ # but BIP21 (and likely other uri based specs) requires urlencoding,
+ # so we re-encode percent-quoted if a known 'scheme' is found in the string
+ # (unknown schemes might be found when a colon is in a serialized TX, which
+ # leads to mangling of the tx, so we check for supported schemes.)
+ uri = urllib.parse.urlparse(qstr)
+ if uri.scheme and uri.scheme in ['bitcoin', 'lightning']:
+ # urlencode request parameters
+ query = urllib.parse.parse_qs(uri.query)
+ query = urllib.parse.urlencode(query, doseq=True, quote_via=urllib.parse.quote)
+ uri = uri._replace(query=query)
+ qstr = urllib.parse.urlunparse(uri)
+
+ qr = qrcode.main.QRCode(border=self.QR_BORDER, error_correction=self.ERROR_CORRECT_LEVEL)
+
+ # calculate best box_size
+ pixelsize = min(self._max_size, self.MAX_QR_PIXELSIZE)
+ try:
+ qr.add_data(qstr)
+ modules = len(qr.get_matrix())
+ qr.box_size = math.floor(pixelsize/modules)
+ qr.make(fit=True)
+ self.qimg = QImage(modules * qr.box_size, modules * qr.box_size, QImage.Format.Format_RGB32)
+ draw_qr(qr=qr, paint_device=self.qimg)
+ except (ValueError, qrcode.exceptions.DataOverflowError):
+ # fake it
+ modules = 17 + qr.border * 2
+ box_size = math.floor(pixelsize/modules)
+ self.qimg = QImage(box_size * modules, box_size * modules, QImage.Format.Format_RGB32)
+ self.qimg.fill(QColor('gray'))
+ return self.qimg, self.qimg.size()
+
+
+# helper for placing icon exactly where it should go on the QR code
+# pyqt5 is unwilling to accept slots on QEQRImageProvider, so we need to define
+# a separate class (sigh)
+class QEQRImageProviderHelper(QObject):
+ def __init__(self, max_size, parent=None):
+ super().__init__(parent)
+ self._max_size = max_size
+
+ @pyqtSlot(str, result='QVariantMap')
+ def getDimensions(self, qstr):
+ qr = qrcode.QRCode(
+ border=QEQRImageProvider.QR_BORDER,
+ error_correction=QEQRImageProvider.ERROR_CORRECT_LEVEL,
+ )
+
+ # calculate best box_size
+ pixelsize = min(self._max_size, QEQRImageProvider.MAX_QR_PIXELSIZE)
+ try:
+ qr.add_data(qstr)
+ modules = len(qr.get_matrix())
+ valid = True
+ except (ValueError, qrcode.exceptions.DataOverflowError):
+ # fake it
+ modules = 17 + qr.border * 2
+ valid = False
+
+ qr.box_size = math.floor(pixelsize/modules)
+ # calculate icon width in modules
+ icon_modules = int(modules / 5)
+ icon_modules += (icon_modules+1) % 2 # force odd
+
+ return {
+ 'qr_pixelsize': modules * qr.box_size,
+ 'icon_pixelsize': icon_modules * qr.box_size,
+ 'valid': valid
+ }
diff --git a/electrum/gui/qml/qeqrscanner.py b/electrum/gui/qml/qeqrscanner.py
new file mode 100644
index 000000000000..be93bbf1f288
--- /dev/null
+++ b/electrum/gui/qml/qeqrscanner.py
@@ -0,0 +1,91 @@
+import os
+
+from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, Qt
+from PyQt6.QtGui import QGuiApplication
+
+from electrum.gui.qml.qetypes import QEBytes
+from electrum.util import send_exception_to_crash_reporter
+from electrum.logging import get_logger
+from electrum.i18n import _
+
+
+if 'ANDROID_DATA' in os.environ:
+ from jnius import autoclass
+ from android import activity
+
+ jpythonActivity = autoclass('org.kivy.android.PythonActivity').mActivity
+ jString = autoclass('java.lang.String')
+ jIntent = autoclass('android.content.Intent')
+
+
+class QEQRScanner(QObject):
+ REQUEST_CODE_SIMPLE_SCANNER_ACTIVITY = 30368 # random 16 bit int
+
+ _logger = get_logger(__name__)
+
+ foundText = pyqtSignal(str)
+ foundBinary = pyqtSignal(QEBytes)
+
+ finished = pyqtSignal()
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._hint = _("Scan a QR code.")
+ self.finished.connect(self._unbind, Qt.ConnectionType.QueuedConnection)
+
+ self.destroyed.connect(lambda: self.on_destroy())
+
+ def on_destroy(self):
+ self._unbind()
+
+ @pyqtProperty(str)
+ def hint(self):
+ return self._hint
+
+ @hint.setter
+ def hint(self, v: str):
+ self._hint = v
+
+ @pyqtSlot()
+ def open(self):
+ if 'ANDROID_DATA' not in os.environ:
+ self._scan_qr_non_android()
+ return
+ jSimpleScannerActivity = autoclass("org.electrum.qr.SimpleScannerActivity")
+ intent = jIntent(jpythonActivity, jSimpleScannerActivity)
+ intent.putExtra(jIntent.EXTRA_TEXT, jString(self._hint))
+
+ activity.bind(on_activity_result=self.on_qr_activity_result)
+ jpythonActivity.startActivityForResult(intent, self.REQUEST_CODE_SIMPLE_SCANNER_ACTIVITY)
+
+ @pyqtSlot()
+ def close(self):
+ # no-op to prevent qml type error
+ pass
+
+ def on_qr_activity_result(self, requestCode, resultCode, intent):
+ if requestCode != self.REQUEST_CODE_SIMPLE_SCANNER_ACTIVITY:
+ self._logger.warning(f"got activity result with invalid {requestCode=}")
+ return
+ try:
+ if resultCode == -1: # RESULT_OK:
+ if (contents := intent.getStringExtra(jString("text"))) is not None:
+ self.foundText.emit(contents)
+ if (contents := intent.getByteArrayExtra(jString("binary"))) is not None:
+ self._binary_content = QEBytes(bytes(contents.tolist()))
+ self.foundBinary.emit(self._binary_content)
+ except Exception as e: # exc would otherwise get lost
+ send_exception_to_crash_reporter(e)
+ finally:
+ self.finished.emit()
+
+ @pyqtSlot()
+ def _unbind(self):
+ if 'ANDROID_DATA' in os.environ:
+ activity.unbind(on_activity_result=self.on_qr_activity_result)
+
+ def _scan_qr_non_android(self):
+ data = QGuiApplication.clipboard().text()
+ self.foundText.emit(data)
+ self.finished.emit()
+ return
diff --git a/electrum/gui/qml/qerequestdetails.py b/electrum/gui/qml/qerequestdetails.py
new file mode 100644
index 000000000000..6a5b76730c89
--- /dev/null
+++ b/electrum/gui/qml/qerequestdetails.py
@@ -0,0 +1,271 @@
+from enum import IntEnum
+from typing import Optional
+from urllib.parse import urlparse
+
+from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QTimer, pyqtEnum, QVariant
+
+from electrum.logging import get_logger
+from electrum.invoices import (
+ PR_UNPAID, PR_EXPIRED, PR_UNKNOWN, PR_PAID, PR_INFLIGHT, PR_FAILED, PR_ROUTING, PR_UNCONFIRMED, LN_EXPIRY_NEVER
+)
+from electrum.lnutil import MIN_FUNDING_SAT, RECEIVED
+from electrum.lnurl import LNURL3Data, request_lnurl_withdraw_callback, LNURLError
+from electrum.payment_identifier import PaymentIdentifier, PaymentIdentifierType
+from electrum.i18n import _
+from electrum.network import Network
+
+from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
+
+from .qewallet import QEWallet
+from .qetypes import QEAmount
+from .util import status_update_timer_interval
+
+
+class QERequestDetails(QObject, QtEventListener):
+
+ @pyqtEnum
+ class Status(IntEnum):
+ Unpaid = PR_UNPAID
+ Expired = PR_EXPIRED
+ Unknown = PR_UNKNOWN
+ Paid = PR_PAID
+ Inflight = PR_INFLIGHT
+ Failed = PR_FAILED
+ Routing = PR_ROUTING
+ Unconfirmed = PR_UNCONFIRMED
+
+ _logger = get_logger(__name__)
+
+ detailsChanged = pyqtSignal() # generic request properties changed signal
+ statusChanged = pyqtSignal()
+ needsLNURLUserInput = pyqtSignal()
+ lnurlError = pyqtSignal(str, str) # code, message
+ busyChanged = pyqtSignal()
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ self._wallet = None # type: Optional[QEWallet]
+ self._key = None
+ self._req = None
+ self._timer = None
+ self._amount = None
+
+ self._lnurlData = None # type: Optional[dict]
+ self._busy = False
+
+ self._timer = QTimer(self)
+ self._timer.setSingleShot(True)
+ self._timer.timeout.connect(self.updateStatusString)
+
+ self.register_callbacks()
+ self.destroyed.connect(lambda: self.on_destroy())
+
+ def on_destroy(self):
+ self.unregister_callbacks()
+ if self._timer:
+ self._timer.stop()
+ self._timer = None
+
+ @qt_event_listener
+ def on_event_request_status(self, wallet, key, status):
+ if self._wallet and wallet == self._wallet.wallet and key == self._key:
+ self._logger.debug('request status %d for key %s' % (status, key))
+ self.statusChanged.emit()
+
+ walletChanged = pyqtSignal()
+ @pyqtProperty(QVariant, notify=walletChanged)
+ def wallet(self) -> QEWallet:
+ return self._wallet
+
+ @wallet.setter
+ def wallet(self, wallet: QEWallet):
+ assert wallet is None or isinstance(wallet, QEWallet)
+ if self._wallet != wallet:
+ self._wallet = wallet
+ self.walletChanged.emit()
+ self.initRequest()
+
+ keyChanged = pyqtSignal()
+ @pyqtProperty(str, notify=keyChanged)
+ def key(self):
+ return self._key
+
+ @key.setter
+ def key(self, key):
+ if self._key != key:
+ self._key = key
+ self._logger.debug(f'key={key}')
+ self.keyChanged.emit()
+ self.initRequest()
+
+ @pyqtProperty(int, notify=statusChanged)
+ def status(self):
+ return self._wallet.wallet.get_invoice_status(self._req)
+
+ @pyqtProperty(str, notify=statusChanged)
+ def status_str(self):
+ return self._req.get_status_str(self.status) if self._req else ''
+
+ @pyqtProperty(bool, notify=detailsChanged)
+ def isLightning(self):
+ return self._req.is_lightning() if self._req else False
+
+ @pyqtProperty(str, notify=detailsChanged)
+ def address(self):
+ addr = self._req.get_address() if self._req else ''
+ return addr if addr else ''
+
+ @pyqtProperty(str, notify=detailsChanged)
+ def message(self):
+ return self._req.get_message() if self._req else ''
+
+ @pyqtProperty(QEAmount, notify=detailsChanged)
+ def amount(self):
+ return self._amount
+
+ @pyqtProperty(int, notify=detailsChanged)
+ def timestamp(self):
+ return self._req.get_time()
+
+ @pyqtProperty(int, notify=detailsChanged)
+ def expiration(self):
+ return self._req.get_expiration_date()
+
+ @pyqtProperty(str, notify=statusChanged)
+ def paidTxid(self):
+ """only used when Request status is PR_PAID"""
+ if not self._req:
+ return ''
+ is_paid, conf_needed, txids = self._wallet.wallet._is_onchain_invoice_paid(self._req)
+ if len(txids) > 0:
+ return txids[0]
+ return ''
+
+ @pyqtProperty(str, notify=detailsChanged)
+ def bolt11(self):
+ wallet = self._wallet.wallet
+ if not wallet.lnworker:
+ return ''
+ amount_sat = self._req.get_amount_sat() or 0 if self._req else 0
+ can_receive = wallet.lnworker.num_sats_can_receive()
+ will_req_zeroconf = wallet.lnworker.receive_requires_jit_channel(amount_msat=amount_sat*1000)
+ if self._req and ((can_receive > 0 and amount_sat <= can_receive)
+ or (will_req_zeroconf and amount_sat >= MIN_FUNDING_SAT)):
+ bolt11 = wallet.get_bolt11_invoice(self._req)
+ else:
+ return ''
+ # encode lightning invoices as uppercase so QR encoding can use
+ # alphanumeric mode; resulting in smaller QR codes
+ bolt11 = bolt11.upper()
+ return bolt11
+
+ @pyqtProperty(str, notify=detailsChanged)
+ def bip21(self):
+ return self._req.get_bip21_URI() if self._req else ''
+
+ @pyqtProperty('QVariantMap', notify=detailsChanged)
+ def lnurlData(self) -> Optional[dict]:
+ return self._lnurlData
+
+ @pyqtProperty(bool, notify=busyChanged)
+ def busy(self):
+ return self._busy
+
+ def initRequest(self):
+ if self._wallet is None or self._key is None:
+ return
+
+ self._req = self._wallet.wallet.get_request(self._key)
+
+ if self._req is None:
+ self._logger.error(f'payment request key {self._key} unknown in wallet {self._wallet.name}')
+ return
+
+ self._amount = QEAmount(from_invoice=self._req)
+
+ self.detailsChanged.emit()
+ self.statusChanged.emit()
+ self.set_status_timer()
+
+ def set_status_timer(self):
+ if self.status == PR_UNPAID:
+ if self.expiration > 0 and self.expiration != LN_EXPIRY_NEVER:
+ self._logger.debug(f'set_status_timer, expiration={self.expiration}')
+ interval = status_update_timer_interval(self.expiration)
+ if interval > 0:
+ self._logger.debug(f'setting status update timer to {interval}')
+ self._timer.setInterval(interval) # msec
+ self._timer.start()
+
+ @pyqtSlot()
+ def updateStatusString(self):
+ self.statusChanged.emit()
+ self.set_status_timer()
+
+ @pyqtSlot(object)
+ def fromResolvedPaymentIdentifier(self, resolved_pi: PaymentIdentifier) -> None:
+ """
+ Called when a payment identifier is resolved to a request (currently only LNURLW, but
+ could also be used for other "voucher" type input like redeeming ecash tokens or
+ some bolt12 thing).
+ """
+ if not self._wallet:
+ return
+ if resolved_pi.type == PaymentIdentifierType.LNURLW:
+ lnurldata = resolved_pi.lnurl_data
+ assert isinstance(lnurldata, LNURL3Data), "Expected LNURL3Data type"
+ self._lnurlData = {
+ 'domain': urlparse(lnurldata.callback_url).netloc,
+ 'callback_url': lnurldata.callback_url,
+ 'min_withdrawable_sat': lnurldata.min_withdrawable_sat,
+ 'max_withdrawable_sat': lnurldata.max_withdrawable_sat,
+ 'default_description': lnurldata.default_description,
+ 'k1': lnurldata.k1,
+ }
+ self.needsLNURLUserInput.emit()
+ else:
+ raise NotImplementedError("Cannot request withdrawal for this payment identifier type")
+
+ @pyqtSlot(int)
+ def lnurlRequestWithdrawal(self, amount_sat: int) -> None:
+ assert self._lnurlData
+ self._logger.debug(f'requesting lnurlw: {repr(self._lnurlData)}')
+
+ try:
+ key = self._wallet.wallet.create_request(
+ amount_sat=amount_sat,
+ message=self._lnurlData.get('default_description', ''),
+ exp_delay=120,
+ address=None,
+ )
+ req = self._wallet.wallet.get_request(key)
+ info = self._wallet.wallet.lnworker.get_payment_info(req.payment_hash, direction=RECEIVED)
+ _lnaddr, b11_invoice = self._wallet.wallet.lnworker.get_bolt11_invoice(
+ payment_info=info,
+ message=req.get_message(),
+ fallback_address=None,
+ )
+ except Exception as e:
+ self._logger.exception('')
+ self.lnurlError.emit(
+ 'lnurl',
+ _("Failed to create payment request for withdrawal: {}").format(str(e))
+ )
+ return
+
+ self._busy = True
+ self.busyChanged.emit()
+
+ coro = request_lnurl_withdraw_callback(
+ callback_url=self._lnurlData['callback_url'],
+ k1=self._lnurlData['k1'],
+ bolt_11=b11_invoice,
+ )
+ try:
+ Network.run_from_another_thread(coro)
+ except LNURLError as e:
+ self.lnurlError.emit('lnurl', str(e))
+ finally:
+ self._busy = False
+ self.busyChanged.emit()
diff --git a/electrum/gui/qml/qeserverlistmodel.py b/electrum/gui/qml/qeserverlistmodel.py
new file mode 100644
index 000000000000..bac6578c0b6e
--- /dev/null
+++ b/electrum/gui/qml/qeserverlistmodel.py
@@ -0,0 +1,129 @@
+from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot
+from PyQt6.QtCore import Qt, QAbstractListModel, QModelIndex
+
+from electrum.logging import get_logger
+from electrum.util import Satoshis
+from electrum.interface import ServerAddr, PREFERRED_NETWORK_PROTOCOL
+from electrum import blockchain
+
+from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
+
+
+class QEServerListModel(QAbstractListModel, QtEventListener):
+ _logger = get_logger(__name__)
+
+ # define listmodel rolemap
+ _ROLE_NAMES=('name', 'address', 'is_connected', 'is_primary', 'is_tor', 'chain', 'height')
+ _ROLE_KEYS = range(Qt.ItemDataRole.UserRole, Qt.ItemDataRole.UserRole + len(_ROLE_NAMES))
+ _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES]))
+ _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS))
+
+ def __init__(self, network, parent=None):
+ super().__init__(parent)
+
+ self._chaintips = 0
+ self._servers = []
+
+ self.network = network
+ self.initModel()
+ self.register_callbacks()
+ self.destroyed.connect(lambda: self.unregister_callbacks())
+
+ @qt_event_listener
+ def on_event_network_updated(self):
+ self._logger.info(f'network updated')
+ self.initModel()
+
+ @qt_event_listener
+ def on_event_blockchain_updated(self):
+ self._logger.info(f'blockchain updated')
+ self.initModel()
+
+ @qt_event_listener
+ def on_event_default_server_changed(self):
+ self._logger.info(f'default server changed')
+ self.initModel()
+
+ def rowCount(self, index):
+ return len(self._servers)
+
+ def roleNames(self):
+ return self._ROLE_MAP
+
+ def data(self, index, role):
+ server = self._servers[index.row()]
+ role_index = role - Qt.ItemDataRole.UserRole
+ value = server[self._ROLE_NAMES[role_index]]
+
+ if isinstance(value, (bool, list, int, str)) or value is None:
+ return value
+ if isinstance(value, Satoshis):
+ return value.value
+ return str(value)
+
+ def clear(self):
+ self.beginResetModel()
+ self._servers = []
+ self.endResetModel()
+
+ chaintipsChanged = pyqtSignal()
+ @pyqtProperty(int, notify=chaintipsChanged)
+ def chaintips(self):
+ return self._chaintips
+
+ def get_chains(self):
+ chains = self.network.get_blockchains()
+ n_chains = len(chains)
+ if n_chains != self._chaintips:
+ self._chaintips = n_chains
+ self.chaintipsChanged.emit()
+ return chains
+
+ @pyqtSlot()
+ def initModel(self):
+ self.clear()
+
+ servers = []
+
+ chains = self.get_chains()
+
+ for chain_id, interfaces in chains.items():
+ self._logger.debug(f'chain {chain_id} has {len(interfaces)} interfaces')
+ b = blockchain.blockchains.get(chain_id)
+ if b is None:
+ continue
+
+ name = b.get_name()
+
+ self._logger.debug(f'chain {chain_id} has name={name}, max_forkpoint=@{b.get_max_forkpoint()}, height={b.height()}')
+
+ for i in interfaces:
+ server = {
+ 'chain': name,
+ 'chain_height': b.height(),
+ 'is_primary': i == self.network.interface,
+ 'is_connected': True,
+ 'name': str(i.server),
+ 'address': i.server.to_friendly_name(),
+ 'height': i.tip
+ }
+
+ servers.append(server)
+
+ # disconnected servers
+ for s in self.network.get_disconnected_server_addrs():
+ server = {
+ 'chain': '',
+ 'chain_height': 0,
+ 'height': 0,
+ 'is_primary': False,
+ 'is_connected': False,
+ 'name': s.to_friendly_name()
+ }
+ server['address'] = server['name']
+
+ servers.append(server)
+
+ self.beginInsertRows(QModelIndex(), 0, len(servers) - 1)
+ self._servers = servers
+ self.endInsertRows()
diff --git a/electrum/gui/qml/qeswaphelper.py b/electrum/gui/qml/qeswaphelper.py
new file mode 100644
index 000000000000..8bbf9483df0c
--- /dev/null
+++ b/electrum/gui/qml/qeswaphelper.py
@@ -0,0 +1,772 @@
+import asyncio
+import bisect
+from enum import IntEnum
+from typing import Union, Optional, TYPE_CHECKING, Sequence
+
+from PyQt6.QtCore import (pyqtProperty, pyqtSignal, pyqtSlot, QObject, QTimer, pyqtEnum, QAbstractListModel, Qt,
+ QModelIndex, QVariant)
+from PyQt6.QtGui import QColor
+
+from electrum.i18n import _
+from electrum.bitcoin import DummyAddress
+from electrum.logging import get_logger
+from electrum.transaction import PartialTxOutput, PartialTransaction
+from electrum.util import (NotEnoughFunds, NoDynamicFeeEstimates, profiler, get_asyncio_loop, age,
+ wait_for2, send_exception_to_crash_reporter)
+from electrum.submarine_swaps import NostrTransport, SwapServerTransport, pubkey_to_rgb_color
+from electrum.fee_policy import FeePolicy
+
+from electrum.gui import messages
+
+from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
+
+from .auth import AuthMixin, auth_protect
+from .qetypes import QEAmount
+from .qewallet import QEWallet
+
+if TYPE_CHECKING:
+ import concurrent.futures
+ from electrum.submarine_swaps import SwapOffer
+
+
+class InvalidSwapParameters(Exception): pass
+
+
+class QESwapServerNPubListModel(QAbstractListModel):
+ _logger = get_logger(__name__)
+
+ # define listmodel rolemap
+ _ROLE_NAMES= ('npub', 'server_pubkey', 'timestamp', 'percentage_fee', 'mining_fee',
+ 'min_amount', 'max_forward_amount', 'max_reverse_amount', 'pow_bits', 'color')
+ _ROLE_KEYS = range(Qt.ItemDataRole.UserRole, Qt.ItemDataRole.UserRole + len(_ROLE_NAMES))
+ _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES]))
+
+ def __init__(self, config, parent=None):
+ super().__init__(parent)
+ self.config = config
+ self._services = []
+
+ def rowCount(self, index):
+ return len(self._services)
+
+ # also expose rowCount as a property
+ countChanged = pyqtSignal()
+ @pyqtProperty(int, notify=countChanged)
+ def count(self):
+ return len(self._services)
+
+ def roleNames(self):
+ return self._ROLE_MAP
+
+ def data(self, index, role):
+ service = self._services[index.row()]
+ role_index = role - Qt.ItemDataRole.UserRole
+ value = service[self._ROLE_NAMES[role_index]]
+ if isinstance(value, (bool, list, int, str, QColor)) or value is None:
+ return value
+ return str(value)
+
+ def clear(self):
+ self.beginResetModel()
+ self._services = []
+ self.endResetModel()
+
+ def offer_to_model(self, x: 'SwapOffer'):
+ return {
+ 'npub': x.server_npub,
+ 'server_pubkey': x.server_pubkey,
+ 'percentage_fee': float(x.pairs.percentage),
+ 'mining_fee': x.pairs.mining_fee,
+ 'min_amount': x.pairs.min_amount,
+ 'max_forward_amount': x.pairs.max_forward,
+ 'max_reverse_amount': x.pairs.max_reverse,
+ 'timestamp': age(x.timestamp),
+ 'pow_bits': x.pow_bits,
+ 'color': QColor(*pubkey_to_rgb_color(x.server_pubkey)),
+ }
+
+ def updateModel(self, items: Sequence['SwapOffer']):
+ offers = items.copy()
+
+ remove = []
+
+ for i, x in enumerate(self._services):
+ if matches := list(filter(lambda offer: offer.server_npub == x['npub'], offers)):
+ # update
+ self._services[i] = self.offer_to_model(matches[0])
+ index = self.index(i, 0)
+ self.dataChanged.emit(index, index, self._ROLE_KEYS)
+ offers.remove(matches[0])
+ else:
+ # add offer to remove items
+ remove.append(i)
+
+ # # remove offers from model
+ for ri in reversed(remove):
+ self.beginRemoveRows(QModelIndex(), ri, ri)
+ self._services.pop(ri)
+ self.endRemoveRows()
+
+ # add new offers
+ if offers:
+ for offer in offers:
+ # offers are sorted by pow_bits
+ insertion_index = bisect.bisect_left(
+ self._services,
+ -offer.pow_bits, # negate the values to get ascending order
+ key=lambda service: -service['pow_bits'],
+ )
+ self.beginInsertRows(QModelIndex(), insertion_index, insertion_index)
+ self._services.insert(insertion_index, self.offer_to_model(offer))
+ self.endInsertRows()
+
+ if offers or remove:
+ self.countChanged.emit()
+
+ @pyqtSlot(str, result=int)
+ def indexFor(self, npub: str):
+ for i, item in enumerate(self._services):
+ if npub == item['npub']:
+ return i
+ return -1
+
+
+class QESwapHelper(AuthMixin, QObject, QtEventListener):
+ _logger = get_logger(__name__)
+
+ MESSAGE_SWAP_HOWTO = ' '.join([
+ _('Move the slider to set the amount and direction of the swap.'),
+ _('Swapping lightning funds for onchain funds will increase your capacity to receive lightning payments.'),
+ ])
+
+ @pyqtEnum
+ class State(IntEnum):
+ Initializing = 0
+ Initialized = 1
+ NoService = 2
+ ServiceReady = 3
+ Started = 4
+ Failed = 5
+ Success = 6
+ Cancelled = 7
+
+ confirm = pyqtSignal([str], arguments=['message'])
+ error = pyqtSignal([str], arguments=['message'])
+ undefinedNPub = pyqtSignal()
+ offersUpdated = pyqtSignal()
+ requestTxUpdate = pyqtSignal()
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ self._wallet = None # type: Optional[QEWallet]
+ self._sliderPos = 0
+ self._rangeMin = -1
+ self._rangeMax = 1
+ self._tx = None
+ self._valid = False
+ self._state = QESwapHelper.State.Initialized
+ self._userinfo = QESwapHelper.MESSAGE_SWAP_HOWTO
+ self._tosend = QEAmount()
+ self._toreceive = QEAmount()
+ self._serverfeeperc = ''
+ self._server_miningfee = QEAmount()
+ self._miningfee = QEAmount()
+ self._isReverse = False
+ self._canCancel = False
+ self._swap = None
+ self._fut_htlc_wait = None
+
+ self._service_available = False
+ self._send_amount = 0
+ self._receive_amount = 0
+
+ self._leftVoid = 0
+ self._rightVoid = 0
+
+ self._available_swapservers = None
+
+ self.register_callbacks()
+ self.destroyed.connect(lambda: self.on_destroy())
+
+ self._fwd_swap_updatetx_timer = QTimer(self)
+ self._fwd_swap_updatetx_timer.setSingleShot(True)
+ self._fwd_swap_updatetx_timer.timeout.connect(self.fwd_swap_updatetx)
+ self.requestTxUpdate.connect(self.tx_update_pushback_timer)
+
+ self.offersUpdated.connect(self.on_offers_updated)
+ self.transport_task: Optional[asyncio.Task] = None
+ self.swap_transport: Optional[SwapServerTransport] = None
+ self.recent_offers = []
+
+ def on_destroy(self):
+ if self.transport_task is not None:
+ self.transport_task.cancel()
+ self.unregister_callbacks()
+
+ walletChanged = pyqtSignal()
+ @pyqtProperty(QVariant, notify=walletChanged)
+ def wallet(self) -> QEWallet:
+ return self._wallet
+
+ @wallet.setter
+ def wallet(self, wallet: QEWallet):
+ assert wallet is None or isinstance(wallet, QEWallet)
+ if self._wallet != wallet:
+ self._wallet = wallet
+ self.run_swap_manager()
+ self.walletChanged.emit()
+
+ sliderPosChanged = pyqtSignal()
+ @pyqtProperty(float, notify=sliderPosChanged)
+ def sliderPos(self):
+ return self._sliderPos
+
+ @sliderPos.setter
+ def sliderPos(self, sliderPos):
+ if self._sliderPos != sliderPos:
+ self._sliderPos = sliderPos
+ self.swap_slider_moved()
+ self.sliderPosChanged.emit()
+
+ rangeMinChanged = pyqtSignal()
+ @pyqtProperty(float, notify=rangeMinChanged)
+ def rangeMin(self):
+ return self._rangeMin
+
+ @rangeMin.setter
+ def rangeMin(self, rangeMin):
+ if self._rangeMin != rangeMin:
+ self._rangeMin = rangeMin
+ self.rangeMinChanged.emit()
+
+ rangeMaxChanged = pyqtSignal()
+ @pyqtProperty(float, notify=rangeMaxChanged)
+ def rangeMax(self):
+ return self._rangeMax
+
+ @rangeMax.setter
+ def rangeMax(self, rangeMax):
+ if self._rangeMax != rangeMax:
+ self._rangeMax = rangeMax
+ self.rangeMaxChanged.emit()
+
+ leftVoidChanged = pyqtSignal()
+ @pyqtProperty(float, notify=leftVoidChanged)
+ def leftVoid(self):
+ return self._leftVoid
+
+ rightVoidChanged = pyqtSignal()
+ @pyqtProperty(float, notify=rightVoidChanged)
+ def rightVoid(self):
+ return self._rightVoid
+
+ validChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=validChanged)
+ def valid(self):
+ return self._valid
+
+ @valid.setter
+ def valid(self, valid):
+ if self._valid != valid:
+ self._valid = valid
+ self.validChanged.emit()
+
+ stateChanged = pyqtSignal()
+ @pyqtProperty(int, notify=stateChanged)
+ def state(self):
+ return self._state
+
+ @state.setter
+ def state(self, state):
+ if self._state != state:
+ self._state = state
+ self.stateChanged.emit()
+
+ userinfoChanged = pyqtSignal()
+ @pyqtProperty(str, notify=userinfoChanged)
+ def userinfo(self):
+ return self._userinfo
+
+ @userinfo.setter
+ def userinfo(self, userinfo):
+ if self._userinfo != userinfo:
+ self._userinfo = userinfo
+ self.userinfoChanged.emit()
+
+ tosendChanged = pyqtSignal()
+ @pyqtProperty(QVariant, notify=tosendChanged)
+ def tosend(self) -> QEAmount:
+ return self._tosend
+
+ @tosend.setter
+ def tosend(self, tosend: QEAmount):
+ assert tosend is None or isinstance(tosend, QEAmount)
+ if self._tosend != tosend:
+ self._tosend = tosend
+ self.tosendChanged.emit()
+
+ toreceiveChanged = pyqtSignal()
+ @pyqtProperty(QVariant, notify=toreceiveChanged)
+ def toreceive(self) -> QEAmount:
+ return self._toreceive
+
+ @toreceive.setter
+ def toreceive(self, toreceive: QEAmount):
+ assert toreceive is None or isinstance(toreceive, QEAmount)
+ if self._toreceive != toreceive:
+ self._toreceive = toreceive
+ self.toreceiveChanged.emit()
+
+ serverMiningfeeChanged = pyqtSignal()
+ @pyqtProperty(QVariant, notify=serverMiningfeeChanged)
+ def serverMiningfee(self) -> QEAmount:
+ return self._server_miningfee
+
+ @serverMiningfee.setter
+ def serverMiningfee(self, server_miningfee: QEAmount):
+ assert server_miningfee is None or isinstance(server_miningfee, QEAmount)
+ if self._server_miningfee != server_miningfee:
+ self._server_miningfee = server_miningfee
+ self.serverMiningfeeChanged.emit()
+
+ serverfeepercChanged = pyqtSignal()
+ @pyqtProperty(str, notify=serverfeepercChanged)
+ def serverfeeperc(self):
+ return self._serverfeeperc
+
+ @serverfeeperc.setter
+ def serverfeeperc(self, serverfeeperc):
+ if self._serverfeeperc != serverfeeperc:
+ self._serverfeeperc = serverfeeperc
+ self.serverfeepercChanged.emit()
+
+ miningfeeChanged = pyqtSignal()
+ @pyqtProperty(QVariant, notify=miningfeeChanged)
+ def miningfee(self) -> QEAmount:
+ return self._miningfee
+
+ @miningfee.setter
+ def miningfee(self, miningfee: QEAmount):
+ assert miningfee is None or isinstance(miningfee, QEAmount)
+ if self._miningfee != miningfee:
+ self._miningfee = miningfee
+ self.miningfeeChanged.emit()
+
+ isReverseChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=isReverseChanged)
+ def isReverse(self):
+ return self._isReverse
+
+ @isReverse.setter
+ def isReverse(self, isReverse):
+ if self._isReverse != isReverse:
+ self._isReverse = isReverse
+ self.isReverseChanged.emit()
+
+ canCancelChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=canCancelChanged)
+ def canCancel(self):
+ return self._canCancel
+
+ @canCancel.setter
+ def canCancel(self, canCancel):
+ if self._canCancel != canCancel:
+ self._canCancel = canCancel
+ self.canCancelChanged.emit()
+
+ availableSwapServersChanged = pyqtSignal()
+ @pyqtProperty(QESwapServerNPubListModel, notify=availableSwapServersChanged)
+ def availableSwapServers(self):
+ if not self._available_swapservers:
+ self._available_swapservers = QESwapServerNPubListModel(self._wallet.wallet.config)
+
+ return self._available_swapservers
+
+ def on_offers_updated(self):
+ self.availableSwapServers.updateModel(self.recent_offers)
+
+ @pyqtSlot(result=bool)
+ def isNostr(self):
+ return True # TODO
+
+ def run_swap_manager(self):
+ self._logger.debug('run_swap_manager')
+ if (lnworker := self._wallet.wallet.lnworker) is None:
+ return
+ swap_manager = lnworker.swap_manager
+
+ assert not swap_manager.is_server, 'running as swap server not supported'
+
+ # if not self._wallet.wallet.config.SWAPSERVER_URL and not self._wallet.wallet.config.SWAPSERVER_NPUB: # TODO enable nostr
+ # self._logger.debug('nostr is preferred but swapserver npub still undefined')
+
+ # FIXME: clearing is_initialized, we might be called because the npub was changed
+ swap_manager.is_initialized.clear()
+ self.state = QESwapHelper.State.Initialized if swap_manager.is_initialized.is_set() else QESwapHelper.State.Initializing
+
+ swap_transport = swap_manager.create_transport()
+
+ async def swap_transport_task(transport: SwapServerTransport):
+ async with transport:
+ self.swap_transport = transport
+ if not swap_manager.is_initialized.is_set():
+ self.userinfo = _('Initializing...')
+ try:
+ # is_initialized is set if we receive the event of our configured SWAPSERVER_NPUB
+ # This will timeout if no server is configured, or our server didn't publish recently.
+ timeout = transport.connect_timeout + 1
+ await wait_for2(swap_manager.is_initialized.wait(), timeout=timeout)
+ self._logger.debug('swapmanager initialized')
+ self.state = QESwapHelper.State.Initialized
+ except asyncio.TimeoutError:
+ # only fail if we didn't get any offers or couldn't connect at all
+ # otherwise the timeout just means that no offer of the selected npub has
+ # been found (or that there is no npub selected at all), so the prompt should open
+ if isinstance(transport, NostrTransport) and not transport.is_connected.is_set():
+ self.userinfo = _('Error') + ': ' + '\n'.join([
+ _('Could not connect to a Nostr relay.'),
+ _('Please check your relays and network connection')
+ ])
+ self.state = QESwapHelper.State.NoService
+ return
+ elif not isinstance(transport, NostrTransport) or not transport.get_recent_offers():
+ self._logger.debug('Could not find a swap provider.')
+ self.userinfo = _('Could not find a swap provider.')
+ self.state = QESwapHelper.State.NoService
+ return
+ except Exception as e:
+ try: # swaphelper might be destroyed at this point
+ self.userinfo = _('Error') + ': ' + str(e)
+ self.state = QESwapHelper.State.NoService
+ self._logger.error(str(e))
+ except RuntimeError:
+ pass
+ return
+
+ if isinstance(transport, NostrTransport) and not swap_manager.is_initialized.is_set():
+ # not is_initialized.is_set() = configured provider was not found (or no provider configured)
+ # prompt user to select a swapserver
+ self.recent_offers = transport.get_recent_offers()
+ self.offersUpdated.emit()
+ self.undefinedNPub.emit()
+ elif swap_manager.is_initialized.is_set():
+ self.setReadyState()
+
+ while True:
+ # keep fetching new incoming offer events
+ # the slider range will not get updated continuously as it would irritate the user
+ if isinstance(transport, NostrTransport):
+ if (recent_offers := transport.get_recent_offers()) != self.recent_offers:
+ self._logger.debug(f"received new swap offer")
+ self.recent_offers = recent_offers
+ self.offersUpdated.emit()
+ await asyncio.sleep(1)
+
+ def transport_closed_cb(fut: 'concurrent.futures.Future'):
+ self.transport_task = None
+ if fut.cancelled():
+ return
+ exc = fut.exception()
+ if exc:
+ send_exception_to_crash_reporter(exc)
+
+ self.transport_task = asyncio.run_coroutine_threadsafe(
+ swap_transport_task(swap_transport),
+ get_asyncio_loop()
+ )
+ self.transport_task.add_done_callback(transport_closed_cb)
+
+ @pyqtSlot()
+ def npubSelectionCancelled(self):
+ if (self._wallet.wallet.config.SWAPSERVER_NPUB
+ not in [offer.server_npub for offer in self.recent_offers]):
+ self._logger.debug('nostr is preferred but swapserver npub still undefined')
+ if not self._wallet.wallet.config.SWAPSERVER_NPUB:
+ self.userinfo = _('No swap provider selected.')
+ else:
+ self.userinfo = _('Select one of the available swap providers.')
+ self.state = QESwapHelper.State.NoService
+
+ @pyqtSlot()
+ def setReadyState(self):
+ if self._wallet.wallet.config.SWAPSERVER_NPUB \
+ or not isinstance(self.swap_transport, NostrTransport):
+ self.state = QESwapHelper.State.ServiceReady
+ self.userinfo = QESwapHelper.MESSAGE_SWAP_HOWTO
+ self.initSwapSliderRange()
+
+ def update_swap_manager_pair(self):
+ """Updates the swap manager pairs to the recent pairs of the selected server"""
+ assert self.swap_transport is not None, "No swap transport"
+ if isinstance(self.swap_transport, NostrTransport):
+ swap_manager = self._wallet.wallet.lnworker.swap_manager
+ pair = self.swap_transport.get_offer(self._wallet.wallet.config.SWAPSERVER_NPUB)
+ swap_manager.update_pairs(pair.pairs)
+
+ @pyqtSlot()
+ def initSwapSliderRange(self):
+ lnworker = self._wallet.wallet.lnworker
+ swap_manager = lnworker.swap_manager
+ # update the swap_manager pair so the newest available data is used below
+ self.update_swap_manager_pair()
+
+ """Sets the minimal and maximal amount that can be swapped for the swap
+ slider."""
+ # tx is updated again afterwards with send_amount in case of normal swap
+ # this is just to estimate the maximal spendable onchain amount for HTLC
+ self.update_tx('!')
+ try:
+ max_onchain_spend = self._tx.output_value_for_address(DummyAddress.SWAP)
+ except AttributeError: # happens if there are no utxos
+ max_onchain_spend = 0
+ reverse = int(min(lnworker.num_sats_can_send(),
+ swap_manager.get_provider_max_forward_amount()))
+ max_recv_amt_ln = min(swap_manager.get_provider_max_reverse_amount(), int(lnworker.num_sats_can_receive()))
+ max_recv_amt_oc = swap_manager.get_send_amount(max_recv_amt_ln, is_reverse=False) or 0
+ forward = int(min(max_recv_amt_oc,
+ # maximally supported swap amount by provider
+ swap_manager.get_provider_max_reverse_amount(),
+ max_onchain_spend))
+ # we expect range to adjust the value of the swap slider to be in the
+ # correct range, i.e., to correct an overflow when reducing the limits
+ self._logger.debug(f'Slider range {-reverse} - {forward}. Pos {self._sliderPos}')
+ self.rangeMin = -reverse
+ self.rangeMax = forward
+ # percentage of void, right or left
+ if reverse < forward:
+ self._leftVoid = 0.5 * (forward - reverse) / forward
+ self._rightVoid = 0
+ elif reverse > forward:
+ self._leftVoid = 0
+ self._rightVoid = - 0.5 * (forward - reverse) / reverse
+ else:
+ self._leftVoid = 0
+ self._rightVoid = 0
+ self.leftVoidChanged.emit()
+ self.rightVoidChanged.emit()
+
+ if not self.rangeMin <= self._sliderPos <= self.rangeMax:
+ # clamp the slider pos into the given limits
+ if abs(self._sliderPos - self.rangeMin) < abs(self._sliderPos - self.rangeMax):
+ self._sliderPos = self.rangeMin
+ else:
+ self._sliderPos = self.rangeMax
+ self.swap_slider_moved()
+
+ @profiler
+ def update_tx(self, onchain_amount: Union[int, str]):
+ """Updates the transaction associated with a forward swap."""
+ if onchain_amount is None:
+ self._tx = None
+ self.valid = False
+ return
+ outputs = [PartialTxOutput.from_address_and_value(DummyAddress.SWAP, onchain_amount)]
+ coins = self._wallet.wallet.get_spendable_coins(None)
+ fee_policy = FeePolicy('eta:2')
+ try:
+ self._tx = self._wallet.wallet.make_unsigned_transaction(
+ coins=coins,
+ outputs=outputs,
+ fee_policy=fee_policy)
+ except (NotEnoughFunds, NoDynamicFeeEstimates):
+ self._tx = None
+ self.valid = False
+
+ @qt_event_listener
+ def on_event_fee_histogram(self, *args):
+ self.swap_slider_moved()
+
+ @qt_event_listener
+ def on_event_fee(self, *args):
+ self.swap_slider_moved()
+
+ def swap_slider_moved(self):
+ if self._state in [QESwapHelper.State.Initializing, QESwapHelper.State.Initialized, QESwapHelper.State.NoService]:
+ return
+
+ position = int(self._sliderPos)
+
+ swap_manager = self._wallet.wallet.lnworker.swap_manager
+
+ # pay_amount and receive_amounts are always with fees already included
+ # so they reflect the net balance change after the swap
+ self.isReverse = (position < 0)
+ self._send_amount = abs(position)
+ self.tosend = QEAmount(amount_sat=self._send_amount)
+ self._receive_amount = swap_manager.get_recv_amount(send_amount=self._send_amount, is_reverse=self.isReverse)
+ self.toreceive = QEAmount(amount_sat=self._receive_amount)
+ # fee breakdown
+ self.serverfeeperc = f'{swap_manager.percentage:0.2f}%'
+ server_miningfee = swap_manager.mining_fee
+ self.serverMiningfee = QEAmount(amount_sat=server_miningfee)
+ if self.isReverse:
+ self.miningfee = QEAmount(amount_sat=swap_manager.get_fee_for_txbatcher())
+ self.check_valid(self._send_amount, self._receive_amount)
+ else:
+ # update tx only if slider isn't moved for a while
+ self.valid = False
+ # trigger tx_update_pushback_timer through signal, as this might be called from other thread
+ self.requestTxUpdate.emit()
+
+ def tx_update_pushback_timer(self):
+ self._fwd_swap_updatetx_timer.start(250)
+
+ def check_valid(self, send_amount, receive_amount):
+ if send_amount and receive_amount:
+ self.valid = True
+ else:
+ # add more nuanced error reporting?
+ self.valid = False
+
+ def fwd_swap_updatetx(self):
+ # if slider is on reverse swap side when timer hits, ignore
+ if self.isReverse:
+ return
+ self.update_tx(self._send_amount)
+ # add lockup fees, but the swap amount is position
+ pay_amount = self._send_amount + self._tx.get_fee() if self._tx else 0
+ self.miningfee = QEAmount(amount_sat=self._tx.get_fee()) if self._tx else QEAmount()
+ self.check_valid(pay_amount, self._receive_amount)
+
+ def do_normal_swap(self, lightning_amount, onchain_amount):
+ assert self._tx
+ if lightning_amount is None or onchain_amount is None:
+ return
+
+ async def swap_task():
+ assert self.swap_transport is not None, "Swap transport not available"
+ try:
+ dummy_tx = self._create_tx(onchain_amount)
+ self.userinfo = _('Performing swap...')
+ self.state = QESwapHelper.State.Started
+ self._swap, invoice = await self._wallet.wallet.lnworker.swap_manager.request_normal_swap(
+ transport=self.swap_transport,
+ lightning_amount_sat=lightning_amount,
+ expected_onchain_amount_sat=onchain_amount,
+ )
+
+ tx = self._wallet.wallet.lnworker.swap_manager.create_funding_tx(self._swap, dummy_tx, password=self._wallet.password)
+ coro2 = self._wallet.wallet.lnworker.swap_manager.wait_for_htlcs_and_broadcast(
+ transport=self.swap_transport, swap=self._swap, invoice=invoice, tx=tx)
+ self._fut_htlc_wait = fut = asyncio.create_task(coro2)
+
+ self.canCancel = True
+ txid = await fut
+ try: # swaphelper might be destroyed at this point
+ if txid:
+ self.userinfo = _('Success!')
+ self.state = QESwapHelper.State.Success
+ else:
+ self.userinfo = _('Swap failed!')
+ self.state = QESwapHelper.State.Failed
+ except RuntimeError:
+ pass
+ except asyncio.CancelledError:
+ self._wallet.wallet.lnworker.swap_manager.cancel_normal_swap(self._swap)
+ self.userinfo = _('Swap cancelled')
+ self.state = QESwapHelper.State.Cancelled
+ except Exception as e:
+ try: # swaphelper might be destroyed at this point
+ self.state = QESwapHelper.State.Failed
+ self.userinfo = _('Error') + ': ' + str(e)
+ self._logger.error(str(e))
+ except RuntimeError:
+ pass
+ finally:
+ try: # swaphelper might be destroyed at this point
+ self.canCancel = False
+ self._swap = None
+ self._fut_htlc_wait = None
+ except RuntimeError:
+ pass
+
+ asyncio.run_coroutine_threadsafe(swap_task(), get_asyncio_loop())
+
+ def _create_tx(self, onchain_amount: Union[int, str, None]) -> PartialTransaction:
+ # TODO: func taken from qt GUI, this should be common code
+ assert not self.isReverse
+ if onchain_amount is None:
+ raise InvalidSwapParameters("onchain_amount is None")
+ # coins = self.window.get_coins()
+ coins = self._wallet.wallet.get_spendable_coins()
+ if onchain_amount == '!':
+ max_amount = sum(c.value_sats() for c in coins)
+ max_swap_amount = self._wallet.wallet.lnworker.swap_manager.client_max_amount_forward_swap()
+ if max_swap_amount is None:
+ raise InvalidSwapParameters("swap_manager.client_max_amount_forward_swap() is None")
+ if max_amount > max_swap_amount:
+ onchain_amount = max_swap_amount
+ outputs = [PartialTxOutput.from_address_and_value(DummyAddress.SWAP, onchain_amount)]
+ fee_policy = FeePolicy('eta:2')
+ try:
+ tx = self._wallet.wallet.make_unsigned_transaction(
+ coins=coins,
+ outputs=outputs,
+ send_change_to_lightning=False,
+ fee_policy=fee_policy
+ )
+ except (NotEnoughFunds, NoDynamicFeeEstimates) as e:
+ raise InvalidSwapParameters(str(e)) from e
+ return tx
+
+ def do_reverse_swap(self, lightning_amount, onchain_amount):
+ if lightning_amount is None or onchain_amount is None:
+ return
+
+ async def swap_task():
+ assert self.swap_transport is not None, "Swap transport not available"
+ swap_manager = self._wallet.wallet.lnworker.swap_manager
+ try:
+ self.userinfo = _('Performing swap...')
+ self.state = QESwapHelper.State.Started
+ await swap_manager.is_initialized.wait()
+ txid = await swap_manager.reverse_swap(
+ transport=self.swap_transport,
+ lightning_amount_sat=lightning_amount,
+ expected_onchain_amount_sat=onchain_amount + swap_manager.get_fee_for_txbatcher(),
+ prepayment_sat=2 * self.serverMiningfee.satsInt,
+ )
+ try: # swaphelper might be destroyed at this point
+ if txid:
+ self.userinfo = _('Success!')
+ self.state = QESwapHelper.State.Success
+ else:
+ self.userinfo = _('Swap failed!')
+ self.state = QESwapHelper.State.Failed
+ except RuntimeError:
+ pass
+ except Exception as e:
+ try: # swaphelper might be destroyed at this point
+ self.state = QESwapHelper.State.Failed
+ msg = _('Timeout') if isinstance(e, TimeoutError) else str(e)
+ self.userinfo = _('Error') + ': ' + msg
+ self._logger.error(str(e))
+ except RuntimeError:
+ pass
+
+ asyncio.run_coroutine_threadsafe(swap_task(), get_asyncio_loop())
+
+ @pyqtSlot()
+ def executeSwap(self):
+ if not self._wallet.wallet.network:
+ self.error.emit(_("You are offline."))
+ return
+ self._do_execute_swap()
+
+ @auth_protect(message=_('Confirm Lightning swap?'))
+ def _do_execute_swap(self):
+ if self.isReverse:
+ lightning_amount = self._send_amount
+ onchain_amount = self._receive_amount
+ self.do_reverse_swap(lightning_amount, onchain_amount)
+ else:
+ lightning_amount = self._receive_amount
+ onchain_amount = self._send_amount
+ self.do_normal_swap(lightning_amount, onchain_amount)
+
+ @pyqtSlot()
+ def cancelNormalSwap(self):
+ assert self._swap
+ self.canCancel = False
+ self._fut_htlc_wait.cancel()
diff --git a/electrum/gui/qml/qetransactionlistmodel.py b/electrum/gui/qml/qetransactionlistmodel.py
new file mode 100644
index 000000000000..293e60ee3670
--- /dev/null
+++ b/electrum/gui/qml/qetransactionlistmodel.py
@@ -0,0 +1,284 @@
+from datetime import datetime, timedelta
+from typing import TYPE_CHECKING, Dict, Any
+
+from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot
+from PyQt6.QtCore import Qt, QAbstractListModel, QModelIndex
+
+from electrum.logging import get_logger
+from electrum.util import Satoshis, TxMinedInfo
+from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
+
+from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
+
+from .qetypes import QEAmount
+
+if TYPE_CHECKING:
+ from electrum.wallet import Abstract_Wallet
+
+
+class QETransactionListModel(QAbstractListModel, QtEventListener):
+ _logger = get_logger(__name__)
+
+ # define listmodel rolemap
+ _ROLE_NAMES=('txid', 'fee_sat', 'height', 'confirmations', 'timestamp', 'monotonic_timestamp',
+ 'incoming', 'value', 'date', 'label', 'txpos_in_block', 'fee',
+ 'inputs', 'outputs', 'section', 'type', 'lightning', 'payment_hash', 'key', 'complete')
+ _ROLE_KEYS = range(Qt.ItemDataRole.UserRole, Qt.ItemDataRole.UserRole + len(_ROLE_NAMES))
+ _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES]))
+ _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS))
+
+ requestRefresh = pyqtSignal()
+
+ def __init__(self, wallet: 'Abstract_Wallet', parent=None, *, onchain_domain=None, include_lightning=True):
+ super().__init__(parent)
+ self.wallet = wallet
+ self.onchain_domain = onchain_domain
+ self.include_lightning = include_lightning
+
+ self.tx_history = []
+
+ self.register_callbacks()
+ self.destroyed.connect(lambda: self.on_destroy())
+ self.requestRefresh.connect(lambda: self.initModel())
+
+ self._dirty = True
+ self.initModel()
+
+ def on_destroy(self):
+ self.unregister_callbacks()
+
+ @qt_event_listener
+ def on_event_verified(self, wallet, txid, info):
+ if wallet == self.wallet:
+ self._logger.debug('verified event for txid %s' % txid)
+ self.on_tx_verified(txid, info)
+
+ @qt_event_listener
+ def on_event_adb_set_future_tx(self, adb, txid):
+ if adb != self.wallet.adb:
+ return
+ self._logger.debug(f'adb_set_future_tx event for txid {txid}')
+ for i, item in enumerate(self.tx_history):
+ if 'txid' in item and item['txid'] == txid:
+ self._update_future_txitem(i)
+ return
+
+ @qt_event_listener
+ def on_event_fee_histogram(self, histogram):
+ self._logger.debug(f'fee histogram updated')
+ for i, tx_item in enumerate(self.tx_history):
+ if 'height' not in tx_item: # filter to on-chain
+ continue
+ if tx_item['confirmations'] > 0: # filter out already mined
+ continue
+ txid = tx_item['txid']
+ tx = self.wallet.db.get_transaction(txid)
+ if not tx:
+ continue
+ txinfo = self.wallet.get_tx_info(tx)
+ status, status_str = self.wallet.get_tx_status(txid, txinfo.tx_mined_status)
+ tx_item['date'] = status_str
+ index = self.index(i, 0)
+ roles = [self._ROLE_RMAP['date']]
+ self.dataChanged.emit(index, index, roles)
+
+ @qt_event_listener
+ def on_event_labels_received(self, wallet, labels):
+ if wallet == self.wallet:
+ self.initModel(True) # TODO: be less dramatic
+
+ def rowCount(self, index):
+ return len(self.tx_history)
+
+ # also expose rowCount as a property
+ countChanged = pyqtSignal()
+ @pyqtProperty(int, notify=countChanged)
+ def count(self):
+ return len(self.tx_history)
+
+ def roleNames(self):
+ return self._ROLE_MAP
+
+ def data(self, index, role):
+ tx = self.tx_history[index.row()]
+ role_index = role - Qt.ItemDataRole.UserRole
+
+ try:
+ value = tx[self._ROLE_NAMES[role_index]]
+ except KeyError as e:
+ self._logger.error(f'non-existing key "{self._ROLE_NAMES[role_index]}" requested')
+ value = None
+
+ if isinstance(value, (bool, list, int, str, QEAmount)) or value is None:
+ return value
+ if isinstance(value, Satoshis):
+ return value.value
+ return str(value)
+
+ @pyqtSlot()
+ def setDirty(self):
+ self._dirty = True
+
+ def clear(self):
+ self.beginResetModel()
+ self.tx_history = []
+ self.endResetModel()
+
+ def tx_to_model(self, tx_item):
+ #self._logger.debug(str(tx_item))
+ item = tx_item
+
+ item['key'] = item.get('txid') or item['payment_hash'] or item['group_id'] # fixme: this is fragile
+
+ if 'lightning' not in item:
+ item['lightning'] = False
+
+ if item['lightning']:
+ item['value'] = QEAmount(amount_sat=item['value'].value, amount_msat=item['amount_msat'])
+ item['incoming'] = True if item['amount_msat'] > 0 else False
+ item['confirmations'] = 0
+ else:
+ item['value'] = QEAmount(amount_sat=item['value'].value)
+
+ if 'txid' in item:
+ tx = self.wallet.db.get_transaction(item['txid'])
+ if tx:
+ item['complete'] = tx.is_complete()
+ else: # due to races, tx might have already been removed from history
+ item['complete'] = False
+
+ # newly arriving txs, or (partially/fully signed) local txs have no (block) timestamp
+ # FIXME just use wallet.get_tx_status, and change that as needed
+ if not item['timestamp']: # onchain: local or mempool or unverified txs
+ if not item['lightning']:
+ txid = item['txid']
+ assert txid
+ tx_mined_info = self._tx_mined_info_from_tx_item(tx_item)
+ item['section'] = 'local' if tx_mined_info.is_local_like() else 'mempool'
+ status, status_str = self.wallet.get_tx_status(txid, tx_mined_info=tx_mined_info)
+ item['date'] = status_str
+ else: # lightning or already mined (and SPV-ed) onchain txs
+ item['section'] = self.get_section_by_timestamp(item['timestamp'])
+ item['date'] = self.format_date_by_section(item['section'], datetime.fromtimestamp(item['timestamp']))
+
+ return item
+
+ @staticmethod
+ def get_section_by_timestamp(timestamp):
+ txts = datetime.fromtimestamp(timestamp)
+ today = datetime.today().replace(hour=0, minute=0, second=0, microsecond=0)
+
+ if txts > today:
+ return 'today'
+ elif txts > today - timedelta(days=1):
+ return 'yesterday'
+ elif txts > today - timedelta(days=7):
+ return 'lastweek'
+ elif txts > today - timedelta(days=31):
+ return 'lastmonth'
+ else:
+ return 'older'
+
+ @staticmethod
+ def format_date_by_section(section: str, date: datetime):
+ # TODO: l10n
+ dfmt = {
+ 'today': '%H:%M',
+ 'yesterday': '%H:%M',
+ 'lastweek': '%a, %H:%M',
+ 'lastmonth': '%a %d, %H:%M',
+ 'older': '%Y-%m-%d %H:%M'
+ }
+ if section not in dfmt:
+ section = 'older'
+ return date.strftime(dfmt[section])
+
+ @staticmethod
+ def _tx_mined_info_from_tx_item(tx_item: Dict[str, Any]) -> TxMinedInfo:
+ # FIXME a bit hackish to have to reconstruct the TxMinedInfo... same thing in qt-gui
+ tx_mined_info = TxMinedInfo(
+ _height=tx_item['height'],
+ conf=tx_item['confirmations'],
+ timestamp=tx_item['timestamp'],
+ wanted_height=tx_item.get('wanted_height', None),
+ )
+ return tx_mined_info
+
+ @pyqtSlot()
+ @pyqtSlot(bool)
+ def initModel(self, force: bool = False):
+ # only (re)construct if dirty or forced
+ if not self._dirty and not force:
+ return
+
+ self._logger.debug('retrieving history')
+ history = self.wallet.get_full_history(
+ onchain_domain=self.onchain_domain,
+ include_lightning=self.include_lightning,
+ )
+ txs = []
+ for key, tx in history.items():
+ txs.append(self.tx_to_model(tx))
+
+ self.clear()
+ self.beginInsertRows(QModelIndex(), 0, len(txs) - 1)
+ self.tx_history = txs
+ self.tx_history.reverse()
+ self.endInsertRows()
+
+ self.countChanged.emit()
+
+ self._dirty = False
+
+ def on_tx_verified(self, txid: str, info: TxMinedInfo):
+ for i, tx in enumerate(self.tx_history):
+ if 'txid' in tx and tx['txid'] == txid:
+ tx['height'] = info.height()
+ tx['confirmations'] = info.conf
+ tx['timestamp'] = info.timestamp
+ tx['section'] = self.get_section_by_timestamp(info.timestamp)
+ tx['date'] = self.format_date_by_section(tx['section'], datetime.fromtimestamp(info.timestamp))
+ index = self.index(i, 0)
+ roles = [self._ROLE_RMAP[x] for x in ['section', 'height', 'confirmations', 'timestamp', 'date']]
+ self.dataChanged.emit(index, index, roles)
+ return
+
+ def _update_future_txitem(self, tx_item_idx: int):
+ tx_item = self.tx_history[tx_item_idx]
+ # note: local txs can transition to future, as "future" state is not persisted
+ if tx_item.get('height') not in (TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL):
+ return
+ txid = tx_item['txid']
+ tx = self.wallet.db.get_transaction(txid)
+ if tx is None:
+ return
+ txinfo = self.wallet.get_tx_info(tx)
+ status, status_str = self.wallet.get_tx_status(txid, txinfo.tx_mined_status)
+ tx_item['date'] = status_str
+ # note: if the height changes, that might affect the history order, but we won't re-sort now.
+ tx_item['height'] = self.wallet.adb.get_tx_height(txid).height()
+ index = self.index(tx_item_idx, 0)
+ roles = [self._ROLE_RMAP[x] for x in ['height', 'date']]
+ self.dataChanged.emit(index, index, roles)
+
+ @pyqtSlot(str, str)
+ def updateTxLabel(self, key, label):
+ for i, tx in enumerate(self.tx_history):
+ if tx['key'] == key:
+ tx['label'] = label
+ index = self.index(i, 0)
+ self.dataChanged.emit(index, index, [self._ROLE_RMAP['label']])
+ return
+
+ @pyqtSlot(int)
+ def updateBlockchainHeight(self, height):
+ self._logger.debug('updating height to %d' % height)
+ for i, tx_item in enumerate(self.tx_history):
+ if 'height' in tx_item:
+ if tx_item['height'] > 0:
+ tx_item['confirmations'] = height - tx_item['height'] + 1
+ index = self.index(i, 0)
+ roles = [self._ROLE_RMAP['confirmations']]
+ self.dataChanged.emit(index, index, roles)
+ elif tx_item['height'] in (TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL):
+ self._update_future_txitem(i)
diff --git a/electrum/gui/qml/qetxdetails.py b/electrum/gui/qml/qetxdetails.py
new file mode 100644
index 000000000000..a5e4dabe03fd
--- /dev/null
+++ b/electrum/gui/qml/qetxdetails.py
@@ -0,0 +1,523 @@
+from typing import Optional
+
+from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QVariant
+
+from electrum.i18n import _
+from electrum.logging import get_logger
+from electrum.bitcoin import DummyAddress
+from electrum.util import format_time, TxMinedInfo
+from electrum.transaction import tx_from_any, Transaction, PartialTransaction
+from electrum.network import Network
+from electrum.address_synchronizer import TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_FUTURE
+from electrum.wallet import TxSighashDanger
+from electrum.fee_policy import FeePolicy
+
+from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
+
+from .qewallet import QEWallet
+from .qetypes import QEAmount
+
+
+class QETxDetails(QObject, QtEventListener):
+ _logger = get_logger(__name__)
+
+ confirmRemoveLocalTx = pyqtSignal([str], arguments=['message'])
+ txRemoved = pyqtSignal()
+ saveTxError = pyqtSignal([str, str], arguments=['code', 'message'])
+ saveTxSuccess = pyqtSignal()
+
+ detailsChanged = pyqtSignal()
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.register_callbacks()
+ self.destroyed.connect(lambda: self.on_destroy())
+
+ self._wallet = None # type: Optional[QEWallet]
+ self._txid = ''
+ self._rawtx = ''
+ self._label = ''
+
+ self._tx = None # type: Optional[Transaction]
+
+ self._status = ''
+ self._amount = QEAmount()
+ self._lnamount = QEAmount()
+ self._fee = QEAmount()
+ self._feerate_str = ''
+ self._inputs = []
+ self._outputs = []
+
+ self._is_lightning_funding_tx = False
+ self._can_bump = False
+ self._can_dscancel = False
+ self._can_broadcast = False
+ self._can_cpfp = False
+ self._can_save_as_local = False
+ self._can_remove = False
+ self._can_sign = False
+ self._is_unrelated = False
+ self._is_complete = False
+ self._is_mined = False
+ self._is_rbf_enabled = False
+ self._is_removed = False
+ self._lock_delay = 0
+ self._sighash_danger = TxSighashDanger()
+
+ self._mempool_depth = ''
+ self._in_mempool = False
+
+ self._date = ''
+ self._timestamp = 0
+ self._confirmations = 0
+ self._header_hash = ''
+ self._short_id = ""
+
+ def on_destroy(self):
+ self.unregister_callbacks()
+
+ @qt_event_listener
+ def on_event_verified(self, wallet, txid, info):
+ if wallet == self._wallet.wallet and txid == self._txid:
+ self._logger.debug(f'verified event for our txid {txid}')
+ self.update()
+
+ @qt_event_listener
+ def on_event_new_transaction(self, wallet, tx):
+ if wallet == self._wallet.wallet and tx.txid() == self._txid:
+ self._logger.debug(f'new_transaction event for our txid {self._txid}')
+ self.update()
+
+ @qt_event_listener
+ def on_event_removed_transaction(self, wallet, tx):
+ if wallet == self._wallet.wallet and tx.txid() == self._txid:
+ self._logger.debug(f'removed my transaction {tx.txid()}')
+ self._is_removed = True
+ self.update()
+ self.txRemoved.emit()
+
+ @qt_event_listener
+ def on_event_fee_histogram(self, histogram):
+ if not self._wallet or not self._tx:
+ return
+ self.update()
+
+ walletChanged = pyqtSignal()
+ @pyqtProperty(QVariant, notify=walletChanged)
+ def wallet(self) -> QEWallet:
+ return self._wallet
+
+ @wallet.setter
+ def wallet(self, wallet: QEWallet):
+ assert wallet is None or isinstance(wallet, QEWallet)
+ if self._wallet != wallet:
+ self._wallet = wallet
+ self.walletChanged.emit()
+
+ txidChanged = pyqtSignal()
+ @pyqtProperty(str, notify=txidChanged)
+ def txid(self):
+ return self._txid
+
+ @txid.setter
+ def txid(self, txid: str):
+ if self._txid != txid:
+ self._logger.debug(f'txid set -> {txid}')
+ self._txid = txid
+ self.txidChanged.emit()
+ self.update(from_txid=True)
+
+ @pyqtProperty(str, notify=detailsChanged)
+ def rawtx(self):
+ return self._rawtx
+
+ @rawtx.setter
+ def rawtx(self, rawtx: str):
+ if self._rawtx != rawtx:
+ self._logger.debug(f'rawtx set -> {rawtx}')
+ self._rawtx = rawtx
+ if not rawtx:
+ return
+ try:
+ self._tx = tx_from_any(rawtx, deserialize=True)
+ self._txid = self._tx.txid()
+ self.txidChanged.emit()
+ self.update()
+ except Exception as e:
+ self._tx = None
+ self._logger.error(repr(e))
+
+ labelChanged = pyqtSignal()
+ @pyqtProperty(str, notify=labelChanged)
+ def label(self):
+ return self._label
+
+ @pyqtSlot(str)
+ def setLabel(self, label: str):
+ if label != self._label:
+ self._wallet.wallet.set_label(self._txid, label)
+ self._label = label
+ self.labelChanged.emit()
+
+ @pyqtProperty(str, notify=detailsChanged)
+ def status(self):
+ return self._status
+
+ @pyqtProperty(str, notify=detailsChanged)
+ def warning(self):
+ return self._sighash_danger.get_long_message()
+
+ @pyqtProperty(QEAmount, notify=detailsChanged)
+ def amount(self):
+ return self._amount
+
+ @pyqtProperty(QEAmount, notify=detailsChanged)
+ def lnAmount(self):
+ return self._lnamount
+
+ @pyqtProperty(QEAmount, notify=detailsChanged)
+ def fee(self):
+ return self._fee
+
+ @pyqtProperty(str, notify=detailsChanged)
+ def feeRateStr(self):
+ return self._feerate_str
+
+ @pyqtProperty('QVariantList', notify=detailsChanged)
+ def inputs(self):
+ return self._inputs
+
+ @pyqtProperty('QVariantList', notify=detailsChanged)
+ def outputs(self):
+ return self._outputs
+
+ @pyqtProperty(bool, notify=detailsChanged)
+ def isMined(self):
+ return self._is_mined
+
+ @pyqtProperty(bool, notify=detailsChanged)
+ def isRemoved(self):
+ return self._is_removed
+
+ @pyqtProperty(str, notify=detailsChanged)
+ def mempoolDepth(self):
+ return self._mempool_depth
+
+ @pyqtProperty(bool, notify=detailsChanged)
+ def inMempool(self):
+ return self._in_mempool
+
+ @pyqtProperty(str, notify=detailsChanged)
+ def date(self):
+ return self._date
+
+ @pyqtProperty(int, notify=detailsChanged)
+ def timestamp(self):
+ return self._timestamp
+
+ @pyqtProperty(int, notify=detailsChanged)
+ def confirmations(self):
+ return self._confirmations
+
+ @pyqtProperty(str, notify=detailsChanged)
+ def shortId(self):
+ return self._short_id
+
+ @pyqtProperty(str, notify=detailsChanged)
+ def headerHash(self):
+ return self._header_hash
+
+ @pyqtProperty(bool, notify=detailsChanged)
+ def isLightningFundingTx(self):
+ return self._is_lightning_funding_tx
+
+ @pyqtProperty(bool, notify=detailsChanged)
+ def canBump(self):
+ return self._can_bump
+
+ @pyqtProperty(bool, notify=detailsChanged)
+ def canCancel(self):
+ return self._can_dscancel
+
+ @pyqtProperty(bool, notify=detailsChanged)
+ def canBroadcast(self):
+ return self._can_broadcast
+
+ @pyqtProperty(bool, notify=detailsChanged)
+ def canCpfp(self):
+ return self._can_cpfp
+
+ @pyqtProperty(bool, notify=detailsChanged)
+ def canSaveAsLocal(self):
+ return self._can_save_as_local
+
+ @pyqtProperty(bool, notify=detailsChanged)
+ def canRemove(self):
+ return self._can_remove
+
+ @pyqtProperty(bool, notify=detailsChanged)
+ def canSign(self):
+ return self._can_sign
+
+ @pyqtProperty(bool, notify=detailsChanged)
+ def isUnrelated(self):
+ return self._is_unrelated
+
+ @pyqtProperty(bool, notify=detailsChanged)
+ def isComplete(self):
+ return self._is_complete
+
+ @pyqtProperty(bool, notify=detailsChanged)
+ def isRbfEnabled(self):
+ return self._is_rbf_enabled
+
+ @pyqtProperty(int, notify=detailsChanged)
+ def lockDelay(self):
+ return self._lock_delay
+
+ @pyqtProperty(bool, notify=detailsChanged)
+ def shouldConfirm(self):
+ return self._sighash_danger.needs_confirm()
+
+ def update(self, from_txid: bool = False):
+ assert self._wallet
+
+ if self._is_removed:
+ self._logger.debug('tx removed, disable gui options')
+ self._can_broadcast = False
+ self._can_bump = False
+ self._can_dscancel = False
+ self._can_cpfp = False
+ self._can_save_as_local = False
+ self._can_remove = False
+ self._can_sign = False
+ self._mempool_depth = ''
+ self._status = _('removed')
+ self.detailsChanged.emit()
+ return
+
+ if from_txid:
+ self._tx = self._wallet.wallet.db.get_transaction(self._txid)
+ assert self._tx is not None, f'unknown txid "{self._txid}"'
+
+ #self._logger.debug(repr(self._tx.to_json()))
+
+ self._logger.debug('adding info from wallet')
+ self._tx.add_info_from_wallet(self._wallet.wallet)
+ if not self._tx.is_complete() and self._tx.is_missing_info_from_network():
+ Network.run_from_another_thread(
+ self._tx.add_info_from_network(self._wallet.wallet.network, timeout=10)) # FIXME is this needed?...
+
+ sm = self._wallet.wallet.lnworker.swap_manager if self._wallet.wallet.lnworker else None
+
+ self._inputs = list(map(lambda x: {
+ 'short_id': x.prevout.short_name(),
+ 'value': x.value_sats(),
+ 'address': x.address,
+ 'is_mine': self._wallet.wallet.is_mine(x.address),
+ 'is_change': self._wallet.wallet.is_change(x.address),
+ 'is_swap': False if not sm else sm.is_lockup_address_for_a_swap(x.address) or x.address == DummyAddress.SWAP,
+ 'is_accounting': self._wallet.wallet.is_accounting_address(x.address)
+ }, self._tx.inputs()))
+ self._outputs = list(map(lambda x: {
+ 'address': x.get_ui_address_str(),
+ 'value': QEAmount(amount_sat=x.value),
+ 'short_id': '', # TODO
+ 'is_mine': self._wallet.wallet.is_mine(x.get_ui_address_str()),
+ 'is_change': self._wallet.wallet.is_change(x.get_ui_address_str()),
+ 'is_billing': self._wallet.wallet.is_billing_address(x.get_ui_address_str()),
+ 'is_swap': False if not sm else sm.is_lockup_address_for_a_swap(x.get_ui_address_str()) or x.get_ui_address_str() == DummyAddress.SWAP,
+ 'is_accounting': self._wallet.wallet.is_accounting_address(x.get_ui_address_str())
+ }, self._tx.outputs()))
+
+ txinfo = self._wallet.wallet.get_tx_info(self._tx)
+
+ self._logger.debug(repr(txinfo))
+
+ # can be None if outputs unrelated to wallet seed,
+ # e.g. to_local local_force_close commitment CSV-locked p2wsh script
+ if txinfo.amount is None:
+ self._amount.satsInt = 0
+ else:
+ self._amount.satsInt = txinfo.amount
+
+ self._status = txinfo.status
+ self._fee.satsInt = txinfo.fee
+
+ self._feerate_str = ""
+ if txinfo.fee is not None:
+ size = self._tx.estimated_size()
+ fee_per_kb = txinfo.fee / size * 1000
+ self._feerate_str = self._wallet.wallet.config.format_fee_rate(fee_per_kb)
+
+ self._sighash_danger = TxSighashDanger()
+
+ self._lock_delay = 0
+ self._in_mempool = False
+ self._is_mined = False if not txinfo.tx_mined_status else txinfo.tx_mined_status.height() > 0
+ if self._is_mined:
+ self.update_mined_status(txinfo.tx_mined_status)
+ else:
+ if txinfo.tx_mined_status.height() in [TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_UNCONF_PARENT]:
+ self._mempool_depth = FeePolicy.depth_tooltip(txinfo.mempool_depth_bytes)
+ self._in_mempool = True
+ elif txinfo.tx_mined_status.height() == TX_HEIGHT_FUTURE:
+ self._lock_delay = txinfo.tx_mined_status.wanted_height - self._wallet.wallet.adb.get_local_height()
+ if isinstance(self._tx, PartialTransaction):
+ self._sighash_danger = self._wallet.wallet.check_sighash(self._tx)
+
+ if self._wallet.wallet.lnworker:
+ # Calling wallet.get_full_history here is inefficient.
+ # We should probably pass the tx_item to the constructor.
+ full_history = self._wallet.wallet.get_full_history()
+ item = full_history.get('group:' + self._txid)
+ self._lnamount.satsInt = int(item['ln_value'].value) if item else 0
+ else:
+ self._lnamount.satsInt = 0
+
+ self._is_complete = self._tx.is_complete()
+ self._is_rbf_enabled = self._tx.is_rbf_enabled()
+ self._is_unrelated = txinfo.amount is None and self._lnamount.isEmpty
+ self._is_lightning_funding_tx = txinfo.is_lightning_funding_tx
+ self._can_broadcast = txinfo.can_broadcast
+ self._can_bump = txinfo.can_bump
+ self._can_dscancel = txinfo.can_dscancel
+ self._can_cpfp = txinfo.can_cpfp
+ self._can_save_as_local = txinfo.can_save_as_local
+ self._can_remove = txinfo.can_remove
+ self._can_sign = (
+ not self._is_complete
+ and self._wallet.wallet.can_sign(self._tx)
+ and not self._sighash_danger.needs_reject()
+ )
+
+ self.detailsChanged.emit()
+
+ if self._txid:
+ label = self._wallet.wallet.get_label_for_txid(self._txid)
+ if self._label != label:
+ self._label = label
+ self.labelChanged.emit()
+
+ def update_mined_status(self, tx_mined_info: TxMinedInfo):
+ self._mempool_depth = ''
+ self._date = format_time(tx_mined_info.timestamp)
+ self._timestamp = tx_mined_info.timestamp
+ self._confirmations = tx_mined_info.conf
+ self._header_hash = tx_mined_info.header_hash
+ self._short_id = tx_mined_info.short_id() or ""
+
+ @pyqtSlot()
+ def signAndBroadcast(self):
+ self._sign(broadcast=True)
+
+ @pyqtSlot()
+ def sign(self):
+ self._sign(broadcast=False)
+
+ def _sign(self, broadcast):
+ # TODO: connecting/disconnecting signal handlers here is hmm
+ try:
+ if broadcast:
+ self._wallet.broadcastSucceeded.disconnect(self.onBroadcastSucceeded)
+ self._wallet.broadcastFailed.disconnect(self.onBroadcastFailed)
+ except Exception:
+ pass
+
+ if broadcast:
+ self._wallet.broadcastSucceeded.connect(self.onBroadcastSucceeded)
+ self._wallet.broadcastFailed.connect(self.onBroadcastFailed)
+ self._wallet.sign_and_broadcast(self._tx, on_success=self.on_signed_tx)
+ else:
+ self._wallet.sign(self._tx, on_success=self.on_signed_tx)
+
+ # side-effect: signing updates self._tx
+ # we rely on this for broadcast
+
+ def on_signed_tx(self, tx: Transaction):
+ self._logger.debug('on_signed_tx')
+ self.update()
+
+ @pyqtSlot()
+ def broadcast(self):
+ assert self._tx.is_complete()
+
+ try:
+ self._wallet.broadcastFailed.disconnect(self.onBroadcastFailed)
+ except Exception:
+ pass
+ self._wallet.broadcastFailed.connect(self.onBroadcastFailed)
+
+ self._can_broadcast = False
+ self.detailsChanged.emit()
+
+ self._wallet.broadcast(self._tx)
+
+ @pyqtSlot(str)
+ def onBroadcastSucceeded(self, txid):
+ if txid != self._txid:
+ return
+
+ self._logger.debug('onBroadcastSucceeded')
+ try:
+ self._wallet.broadcastSucceeded.disconnect(self.onBroadcastSucceeded)
+ except Exception:
+ pass
+
+ self._can_broadcast = False
+ self.detailsChanged.emit()
+
+ @pyqtSlot(str, str, str)
+ def onBroadcastFailed(self, txid, code, reason):
+ if txid != self._txid:
+ return
+
+ try:
+ self._wallet.broadcastFailed.disconnect(self.onBroadcastFailed)
+ except Exception:
+ pass
+
+ self._can_broadcast = True
+ self.detailsChanged.emit()
+
+ @pyqtSlot()
+ @pyqtSlot(bool)
+ def removeLocalTx(self, confirm=False):
+ assert self._can_remove, 'cannot remove'
+ txid = self._txid
+ assert txid, 'txid unset'
+
+ if not confirm:
+ num_child_txs = len(self._wallet.wallet.adb.get_depending_transactions(txid))
+ question = _("Are you sure you want to remove this transaction?")
+ if num_child_txs > 0:
+ question = (
+ _("Are you sure you want to remove this transaction and {} child transactions?")
+ .format(num_child_txs))
+ self.confirmRemoveLocalTx.emit(question)
+ return
+
+ self._wallet.wallet.adb.remove_transaction(txid)
+ self._wallet.wallet.save_db()
+
+ # NOTE: from here, the tx/txid is unknown and all properties are invalid.
+ # UI should close TxDetails and avoid interacting with this qetxdetails instance.
+ self._tx = None
+
+ @pyqtSlot()
+ def save(self):
+ if not self._tx:
+ return
+
+ if self._wallet.save_tx(self._tx):
+ self._can_save_as_local = False
+ self._can_remove = True
+ self.detailsChanged.emit()
+
+ @pyqtSlot(result='QVariantList')
+ def getSerializedTx(self):
+ txqr = self._tx.to_qr_data()
+ label = ""
+ if txid := self._tx.txid():
+ label = self._wallet.wallet.get_label_for_txid(txid)
+ return [str(self._tx), txqr[0], txqr[1], label]
diff --git a/electrum/gui/qml/qetxfinalizer.py b/electrum/gui/qml/qetxfinalizer.py
new file mode 100644
index 000000000000..d0457c80ecbc
--- /dev/null
+++ b/electrum/gui/qml/qetxfinalizer.py
@@ -0,0 +1,1218 @@
+import copy
+from enum import IntEnum
+import threading
+from decimal import Decimal
+from typing import Optional, TYPE_CHECKING, Callable
+from functools import partial
+
+from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, pyqtEnum, QVariant
+
+from electrum.logging import get_logger
+from electrum.i18n import _
+from electrum.bitcoin import DummyAddress
+from electrum.transaction import PartialTxOutput, PartialTransaction, Transaction, TxOutpoint
+from electrum.util import (
+ NotEnoughFunds, profiler, quantize_feerate, UserFacingException, NoDynamicFeeEstimates, event_listener
+)
+from electrum.wallet import CannotBumpFee, CannotDoubleSpendTx, CannotCPFP, BumpFeeStrategy, sweep_preparations
+from electrum import keystore
+from electrum.plugin import run_hook
+from electrum.fee_policy import FeePolicy, FeeMethod
+from electrum.network import NetworkException
+
+from electrum.gui import messages
+from electrum.gui.common_qt.util import QtEventListener
+
+from .qewallet import QEWallet
+from .qetypes import QEAmount
+
+if TYPE_CHECKING:
+ from electrum.simple_config import SimpleConfig
+
+
+class FeeSlider(QObject):
+
+ @pyqtEnum
+ class FSMethod(IntEnum):
+ FEERATE = 0
+ ETA = 1
+ MEMPOOL = 2
+ MANUAL = 3
+
+ def to_fee_method(self) -> 'FeeMethod':
+ return {
+ self.FEERATE: FeeMethod.FEERATE,
+ self.ETA: FeeMethod.ETA,
+ self.MEMPOOL: FeeMethod.MEMPOOL,
+ self.MANUAL: FeeMethod.FIXED
+ }[self]
+
+ @classmethod
+ def from_fee_method(cls, fm: FeeMethod) -> 'FeeSlider.FSMethod':
+ return {
+ FeeMethod.FEERATE: cls.FEERATE,
+ FeeMethod.ETA: cls.ETA,
+ FeeMethod.MEMPOOL: cls.MEMPOOL,
+ FeeMethod.FIXED: cls.MANUAL
+ }[fm]
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ self._wallet = None # type: Optional[QEWallet]
+ self._sliderSteps = 0
+ self._sliderPos = 0
+ self._fee_method = None # type: Optional[FeeSlider.FSMethod]
+ self._fee_policy = None # type: Optional[FeePolicy]
+ self._target = ''
+ self._config = None # type: Optional[SimpleConfig]
+
+ walletChanged = pyqtSignal()
+ @pyqtProperty(QVariant, notify=walletChanged)
+ def wallet(self) -> QEWallet:
+ return self._wallet
+
+ @wallet.setter
+ def wallet(self, wallet: QEWallet):
+ assert wallet is None or isinstance(wallet, QEWallet)
+ if self._wallet != wallet:
+ self._wallet = wallet
+ self._config = self._wallet.wallet.config
+ self.read_config()
+ self.walletChanged.emit()
+
+ sliderStepsChanged = pyqtSignal()
+ @pyqtProperty(int, notify=sliderStepsChanged)
+ def sliderSteps(self):
+ return self._sliderSteps
+
+ sliderPosChanged = pyqtSignal()
+ @pyqtProperty(int, notify=sliderPosChanged)
+ def sliderPos(self):
+ return self._sliderPos
+
+ @sliderPos.setter
+ def sliderPos(self, sliderPos):
+ if self._sliderPos != sliderPos:
+ self._sliderPos = sliderPos
+ self.save_config()
+ self.sliderPosChanged.emit()
+
+ methodChanged = pyqtSignal()
+ @pyqtProperty(int, notify=methodChanged)
+ def method(self) -> int:
+ fsmethod = self.FSMethod.from_fee_method(self._fee_policy.method)
+ return int(fsmethod)
+
+ @method.setter
+ def method(self, method: int):
+ if self._fee_method != FeeSlider.FSMethod(method):
+ self._fee_method = self.FSMethod(method)
+ self._fee_policy.set_method(self._fee_method.to_fee_method())
+ self.update_slider()
+ self.methodChanged.emit()
+ self.save_config()
+
+ targetChanged = pyqtSignal()
+ @pyqtProperty(str, notify=targetChanged)
+ def target(self):
+ return self._target
+
+ @target.setter
+ def target(self, target):
+ if self._target != target:
+ self._target = target
+ self.targetChanged.emit()
+
+ def update_slider(self):
+ if self._fee_method == FeeSlider.FSMethod.MANUAL:
+ return
+ self._sliderSteps = self._fee_policy.get_slider_max()
+ self._sliderPos = self._fee_policy.get_slider_pos()
+ self.sliderStepsChanged.emit()
+ self.sliderPosChanged.emit()
+
+ def update_target(self):
+ self.target = self._fee_policy.get_target_text()
+
+ def read_config(self):
+ self._fee_policy = FeePolicy(self._config.FEE_POLICY)
+ self._fee_method = self.FSMethod.from_fee_method(self._fee_policy.method)
+ self.update_slider()
+ self.methodChanged.emit()
+ self.update()
+
+ def save_config(self):
+ if self._fee_method != FeeSlider.FSMethod.MANUAL:
+ value = int(self._sliderPos)
+ self._fee_policy.set_value_from_slider_pos(value)
+ self._config.FEE_POLICY = self._fee_policy.get_descriptor()
+ self.update()
+
+ def update(self):
+ raise NotImplementedError()
+
+
+class TxFeeSlider(FeeSlider):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ self._fee = QEAmount()
+ self._feeRate = ''
+ self._userFee = ''
+ self._userFeerate = ''
+ self._is_user_feerate_last = True
+ self._rbf = False
+ self._tx = None # type: Optional[PartialTransaction]
+ self._inputs = []
+ self._outputs = []
+ self._finalized_txid = ''
+ self._valid = False
+ self._warning = ''
+
+ feeChanged = pyqtSignal()
+ @pyqtProperty(QVariant, notify=feeChanged)
+ def fee(self) -> QEAmount:
+ return self._fee
+
+ @fee.setter
+ def fee(self, fee: QEAmount):
+ assert fee is None or isinstance(fee, QEAmount)
+ if self._fee != fee:
+ self._fee.copyFrom(fee)
+ self.feeChanged.emit()
+
+ feeRateChanged = pyqtSignal()
+ @pyqtProperty(str, notify=feeRateChanged)
+ def feeRate(self):
+ return self._feeRate
+
+ @feeRate.setter
+ def feeRate(self, feeRate):
+ if self._feeRate != feeRate:
+ self._feeRate = feeRate
+ self.feeRateChanged.emit()
+
+ userFeeChanged = pyqtSignal()
+ @pyqtProperty(str, notify=userFeeChanged)
+ def userFee(self):
+ return self._userFee
+
+ @userFee.setter
+ def userFee(self, userFee):
+ if self._userFee != userFee:
+ self._logger.warn('userFee')
+ self._userFee = userFee
+ user_fee = int(userFee) if userFee else 0
+ self._fee_policy = FeePolicy(f'fixed:{user_fee}')
+ self.userFeeChanged.emit()
+ self.isUserFeerateLast = False
+ self.update()
+
+ userFeerateChanged = pyqtSignal()
+ @pyqtProperty(str, notify=userFeerateChanged)
+ def userFeerate(self):
+ return self._userFeerate
+
+ @userFeerate.setter
+ def userFeerate(self, userFeerate):
+ if self._userFeerate != userFeerate:
+ self._logger.warn('userFeerate')
+ self._userFeerate = userFeerate
+ as_decimal = Decimal(userFeerate) if userFeerate else 0
+ user_feerate = int(as_decimal * 1000)
+ self._fee_policy = FeePolicy(f'feerate:{user_feerate}')
+ self.userFeerateChanged.emit()
+ self.isUserFeerateLast = True
+ self.update()
+
+ isUserFeerateLastChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=isUserFeerateLastChanged)
+ def isUserFeerateLast(self):
+ return self._is_user_feerate_last
+
+ @isUserFeerateLast.setter
+ def isUserFeerateLast(self, isUserFeerateLast):
+ if self._is_user_feerate_last != isUserFeerateLast:
+ self._is_user_feerate_last = isUserFeerateLast
+ self.isUserFeerateLastChanged.emit()
+
+ rbfChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=rbfChanged)
+ def rbf(self):
+ return self._rbf
+
+ @rbf.setter
+ def rbf(self, rbf):
+ if self._rbf != rbf:
+ self._rbf = rbf
+ self.update()
+ self.rbfChanged.emit()
+
+ inputsChanged = pyqtSignal()
+ @pyqtProperty('QVariantList', notify=inputsChanged)
+ def inputs(self):
+ return self._inputs
+
+ @inputs.setter
+ def inputs(self, inputs):
+ if self._inputs != inputs:
+ self._inputs = inputs
+ self.inputsChanged.emit()
+
+ outputsChanged = pyqtSignal()
+ @pyqtProperty('QVariantList', notify=outputsChanged)
+ def outputs(self):
+ return self._outputs
+
+ @outputs.setter
+ def outputs(self, outputs):
+ if self._outputs != outputs:
+ self._outputs = outputs
+ self.outputsChanged.emit()
+
+ finalizedTxidChanged = pyqtSignal()
+ @pyqtProperty(str, notify=finalizedTxidChanged)
+ def finalizedTxid(self):
+ return self._finalized_txid
+
+ @finalizedTxid.setter
+ def finalizedTxid(self, finalized_txid):
+ if self._finalized_txid != finalized_txid:
+ self._finalized_txid = finalized_txid
+ self.finalizedTxidChanged.emit()
+
+ warningChanged = pyqtSignal()
+ @pyqtProperty(str, notify=warningChanged)
+ def warning(self):
+ return self._warning
+
+ @warning.setter
+ def warning(self, warning):
+ if self._warning != warning:
+ self._warning = warning
+ self.warningChanged.emit()
+
+ validChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=validChanged)
+ def valid(self):
+ return self._valid
+
+ @pyqtSlot()
+ def doUpdate(self):
+ self.update()
+
+ def update_from_tx(self, tx: PartialTransaction):
+ tx_size = tx.estimated_size()
+ fee = tx.get_fee()
+ feerate = Decimal(fee) / tx_size # sat/byte
+
+ self.fee = QEAmount(amount_sat=int(fee))
+ self.feeRate = f'{feerate:.1f}'
+ self.finalizedTxid = tx.txid()
+
+ self.update_inputs_from_tx(tx)
+ self.update_outputs_from_tx(tx)
+ self.update_target()
+ self.update_manual_fields()
+
+ def update_manual_fields(self):
+ if self._fee_method == FeeSlider.FSMethod.MANUAL:
+ if self._fee_policy.method == FeeMethod.FIXED:
+ self._userFeerate = self.feeRate
+ self.userFeerateChanged.emit()
+ else:
+ self._userFee = self.fee.satsStr
+ self.userFeeChanged.emit()
+
+ def update_inputs_from_tx(self, tx: Transaction):
+ inputs = []
+ for inp in tx.inputs():
+ # addr = self.wallet.adb.get_txin_address(txin)
+ addr = inp.address
+ address_str = '' if addr is None else addr
+
+ txin_value = inp.value_sats() if inp.value_sats() else 0
+
+ inputs.append({
+ 'address': address_str,
+ 'short_id': str(inp.short_id),
+ 'value': QEAmount(amount_sat=txin_value),
+ 'is_coinbase': inp.is_coinbase_input(),
+ 'is_mine': self._wallet.wallet.is_mine(addr),
+ 'is_change': self._wallet.wallet.is_change(addr),
+ 'prevout_txid': inp.prevout.txid.hex(),
+ 'is_swap': False
+ })
+ self.inputs = inputs
+
+ def update_outputs_from_tx(self, tx: PartialTransaction):
+ sm = self._wallet.wallet.lnworker.swap_manager if self._wallet.wallet.lnworker else None
+
+ outputs = []
+ for idx, o in enumerate(tx.outputs()):
+ outputs.append({
+ 'address': o.get_ui_address_str(),
+ 'value': o.value,
+ 'short_id': str(TxOutpoint(bytes.fromhex(tx.txid()), idx).short_name()) if tx.txid() else '',
+ 'is_mine': self._wallet.wallet.is_mine(o.get_ui_address_str()),
+ 'is_change': self._wallet.wallet.is_change(o.get_ui_address_str()),
+ 'is_billing': self._wallet.wallet.is_billing_address(o.get_ui_address_str()),
+ 'is_swap': False if not sm else sm.is_lockup_address_for_a_swap(o.get_ui_address_str()) or o.get_ui_address_str() == DummyAddress.SWAP,
+ 'is_accounting': self._wallet.wallet.is_accounting_address(o.get_ui_address_str()),
+ 'is_reserve': o.is_utxo_reserve
+ })
+ self.outputs = outputs
+
+ def update_fee_warning_from_tx(self, *, tx: PartialTransaction, invoice_amt: Optional[int]):
+ if invoice_amt is None:
+ invoice_amt = sum([txo.value for txo in tx.outputs() if not txo.is_mine])
+ if invoice_amt == 0:
+ invoice_amt = tx.output_value()
+ fee_warning_tuple = self._wallet.wallet.get_tx_fee_warning(
+ invoice_amt=invoice_amt, tx_size=tx.estimated_size(), fee=tx.get_fee(), txid=tx.txid())
+ if fee_warning_tuple:
+ allow_send, long_warning, short_warning = fee_warning_tuple
+ self.warning = _('Warning') + ': ' + long_warning
+ else:
+ self.warning = ''
+
+ def save_config(self):
+ if self._fee_method == FeeSlider.FSMethod.MANUAL:
+ if self.fee:
+ self.userFee = self.fee.satsStr
+ if self.feeRate:
+ self.userFeerate = self.feeRate
+ super().save_config()
+
+
+class QETxFinalizer(TxFeeSlider):
+ _logger = get_logger(__name__)
+
+ finished = pyqtSignal([bool, bool, bool], arguments=['signed', 'saved', 'complete'])
+ signError = pyqtSignal([str], arguments=['message'])
+
+ def __init__(
+ self,
+ parent=None,
+ *,
+ make_tx: Callable[[int, FeePolicy], PartialTransaction] = None,
+ accept: Callable[[PartialTransaction], None] = None,
+ ):
+ super().__init__(parent)
+ self.f_make_tx = make_tx
+ self.f_accept = accept
+
+ self._address = ''
+ self._amount = QEAmount()
+ self._effectiveAmount = QEAmount()
+ self._extraFee = QEAmount()
+ self._canRbf = False
+
+ addressChanged = pyqtSignal()
+ @pyqtProperty(str, notify=addressChanged)
+ def address(self):
+ return self._address
+
+ @address.setter
+ def address(self, address):
+ if self._address != address:
+ self._address = address
+ self.addressChanged.emit()
+
+ amountChanged = pyqtSignal()
+ @pyqtProperty(QVariant, notify=amountChanged)
+ def amount(self) -> QEAmount:
+ return self._amount
+
+ @amount.setter
+ def amount(self, amount: QEAmount):
+ assert amount is None or isinstance(amount, QEAmount)
+ if self._amount != amount:
+ self._logger.debug(str(amount))
+ self._amount.copyFrom(amount)
+ self.amountChanged.emit()
+
+ effectiveAmountChanged = pyqtSignal()
+ @pyqtProperty(QEAmount, notify=effectiveAmountChanged)
+ def effectiveAmount(self):
+ return self._effectiveAmount
+
+ extraFeeChanged = pyqtSignal()
+ @pyqtProperty(QVariant, notify=extraFeeChanged)
+ def extraFee(self) -> QEAmount:
+ return self._extraFee
+
+ @extraFee.setter
+ def extraFee(self, extrafee: QEAmount):
+ assert extrafee is None or isinstance(extrafee, QEAmount)
+ if self._extraFee != extrafee:
+ self._extraFee.copyFrom(extrafee)
+ self.extraFeeChanged.emit()
+
+ canRbfChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=canRbfChanged)
+ def canRbf(self):
+ return self._canRbf
+
+ @canRbf.setter
+ def canRbf(self, canRbf):
+ if self._canRbf != canRbf:
+ self._canRbf = canRbf
+ self.canRbfChanged.emit()
+ self.rbf = self._canRbf # if we can RbF, we do RbF
+
+ @profiler
+ def make_tx(self, amount):
+ self._logger.debug(f'make_tx amount={amount}')
+
+ if self.f_make_tx:
+ tx = self.f_make_tx(amount, self._fee_policy)
+ else:
+ # default impl
+ coins = self._wallet.wallet.get_spendable_coins(None)
+ outputs = [PartialTxOutput.from_address_and_value(self.address, amount)]
+ tx = self._wallet.wallet.make_unsigned_transaction(
+ coins=coins,
+ outputs=outputs,
+ fee_policy=self._fee_policy,
+ rbf=self._rbf)
+
+ self._logger.debug('fee: %d, inputs: %d, outputs: %d' % (tx.get_fee(), len(tx.inputs()), len(tx.outputs())))
+
+ return tx
+
+ def update(self):
+ if not self._wallet:
+ self._logger.debug('wallet not set, ignoring update()')
+ return
+
+ try:
+ # make unsigned transaction
+ amount = '!' if self._amount.isMax else self._amount.satsInt
+ tx = self.make_tx(amount=amount)
+ except NotEnoughFunds:
+ self.warning = self._wallet.wallet.get_text_not_enough_funds_mentioning_frozen(for_amount=amount)
+ self._valid = False
+ self.validChanged.emit()
+ return
+ except NoDynamicFeeEstimates:
+ self.warning = _('No dynamic fee estimates available')
+ self._valid = False
+ self.validChanged.emit()
+ return
+ except Exception as e:
+ self._logger.error(str(e))
+ self.warning = repr(e)
+ self._valid = False
+ self.validChanged.emit()
+ return
+
+ self._tx = tx
+
+ amount = self._amount.satsInt if not self._amount.isMax else tx.output_value()
+
+ self._effectiveAmount.satsInt = amount
+ self.effectiveAmountChanged.emit()
+
+ self.update_from_tx(tx)
+
+ x_fee = run_hook('get_tx_extra_fee', self._wallet.wallet, tx)
+ if x_fee:
+ x_fee_address, x_fee_amount = x_fee
+ self.extraFee = QEAmount(amount_sat=x_fee_amount)
+
+ self.update_fee_warning_from_tx(tx=tx, invoice_amt=amount)
+
+ if self._amount.isMax and not self.warning:
+ if reserve_sats := self._wallet.wallet.tx_keeps_ln_utxo_reserve(
+ tx,
+ gui_spend_max=self._amount.isMax
+ ):
+ reserve_str = self._config.format_amount_and_units(reserve_sats)
+ self.warning = ' '.join([
+ _('Warning') + ':',
+ _('Could not spend max: a security reserve of {} was kept for your Lightning channels.')
+ .format(reserve_str)
+ ])
+
+ self._valid = True
+ self.validChanged.emit()
+
+ @pyqtSlot()
+ def saveOrShow(self):
+ if not self._valid or not self._tx:
+ self._logger.debug('no valid tx')
+ return
+
+ saved = False
+ if self._tx.txid():
+ if self._wallet.save_tx(self._tx):
+ saved = True
+
+ self.finished.emit(False, saved, self._tx.is_complete())
+
+ @pyqtSlot()
+ def signAndSend(self):
+ if not self._valid or not self._tx:
+ self._logger.debug('no valid tx')
+ return
+
+ if self.f_accept:
+ self.f_accept(self._tx)
+ return
+
+ self._wallet.sign_and_broadcast(self._tx, on_success=partial(self.on_signed_tx, False), on_failure=self.on_sign_failed)
+
+ @pyqtSlot()
+ def sign(self):
+ if not self._valid or not self._tx:
+ self._logger.error('no valid tx')
+ return
+
+ self._wallet.sign(self._tx, on_success=partial(self.on_signed_tx, True), on_failure=self.on_sign_failed)
+
+ def on_signed_tx(self, save: bool, tx: Transaction):
+ self._logger.debug('on_signed_tx')
+ saved = False
+ if save and self._tx.txid():
+ if self._wallet.save_tx(self._tx):
+ saved = True
+ else:
+ self._logger.error('Could not save tx')
+ self.finished.emit(True, saved, tx.is_complete())
+
+ def on_sign_failed(self, msg: str = None):
+ self._logger.debug('on_sign_failed')
+ self.signError.emit(msg)
+
+ @pyqtSlot(result='QVariantList')
+ def getSerializedTx(self):
+ txqr = self._tx.to_qr_data()
+ label = ""
+ if txid := self._tx.txid():
+ label = self._wallet.wallet.get_label_for_txid(txid)
+ return [str(self._tx), txqr[0], txqr[1], label]
+
+
+class TxMonMixin(QtEventListener):
+ """ mixin for watching an existing TX based on its txid for verified or removed event.
+ requires self._wallet to contain a QEWallet instance.
+ exposes txid qt property.
+ calls get_tx() once txid is set.
+ calls tx_verified() and emits txMined signal once tx is verified.
+ emits txRemoved signal if tx is removed (e.g. replace-by-fee)
+ """
+ txMined = pyqtSignal()
+ txRemoved = pyqtSignal()
+
+ def __init__(self, parent=None):
+ self._logger.debug('TxMonMixin.__init__')
+
+ self._txid = ''
+
+ self.register_callbacks()
+ self.destroyed.connect(lambda: self.on_destroy())
+
+ def on_destroy(self):
+ self.unregister_callbacks()
+
+ @event_listener
+ def on_event_verified(self, wallet, txid, info):
+ if wallet == self._wallet.wallet and txid == self._txid:
+ self._logger.debug('verified event for our txid %s' % txid)
+ self.tx_verified()
+ self.txMined.emit()
+
+ @event_listener
+ def on_event_removed_transaction(self, wallet, tx):
+ if wallet == self._wallet.wallet and tx.txid() == self._txid:
+ self._logger.debug('remove tx for our txid %s' % self._txid)
+ self.tx_removed()
+ self.txRemoved.emit()
+
+ txidChanged = pyqtSignal()
+ @pyqtProperty(str, notify=txidChanged)
+ def txid(self):
+ return self._txid
+
+ @txid.setter
+ def txid(self, txid):
+ if self._txid != txid:
+ self._txid = txid
+ self.get_tx()
+ self.txidChanged.emit()
+
+ # override
+ def get_tx(self) -> None:
+ pass
+
+ # override
+ def tx_verified(self) -> None:
+ pass
+
+ # override
+ def tx_removed(self) -> None:
+ pass
+
+
+class QETxRbfFeeBumper(TxFeeSlider, TxMonMixin):
+ _logger = get_logger(__name__)
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ self._oldfee = QEAmount()
+ self._oldfee_rate = '0'
+ self._orig_tx = None
+ self._rbf = True
+ self._bump_method = BumpFeeStrategy.PRESERVE_PAYMENT.name
+ self._bump_methods_available = []
+
+ oldfeeChanged = pyqtSignal()
+ @pyqtProperty(QVariant, notify=oldfeeChanged)
+ def oldfee(self) -> QEAmount:
+ return self._oldfee
+
+ @oldfee.setter
+ def oldfee(self, oldfee: QEAmount):
+ assert oldfee is None or isinstance(oldfee, QEAmount)
+ if self._oldfee != oldfee:
+ self._oldfee.copyFrom(oldfee)
+ self.oldfeeChanged.emit()
+
+ oldfeeRateChanged = pyqtSignal()
+ @pyqtProperty(str, notify=oldfeeRateChanged)
+ def oldfeeRate(self):
+ return self._oldfee_rate
+
+ @oldfeeRate.setter
+ def oldfeeRate(self, oldfeerate):
+ if self._oldfee_rate != oldfeerate:
+ self._oldfee_rate = oldfeerate
+ self.oldfeeRateChanged.emit()
+
+ bumpMethodChanged = pyqtSignal()
+ @pyqtProperty(str, notify=bumpMethodChanged)
+ def bumpMethod(self):
+ return self._bump_method
+
+ @bumpMethod.setter
+ def bumpMethod(self, bumpmethod: str) -> None:
+ if self._bump_method != bumpmethod:
+ self._bump_method = bumpmethod
+ self.bumpMethodChanged.emit()
+ self.update()
+
+ bumpMethodsAvailableChanged = pyqtSignal()
+ @pyqtProperty('QVariantList', notify=bumpMethodsAvailableChanged)
+ def bumpMethodsAvailable(self):
+ return self._bump_methods_available
+
+ def get_tx(self):
+ assert self._txid
+ self._orig_tx = self._wallet.wallet.db.get_transaction(self._txid)
+ assert self._orig_tx
+
+ strategies, def_strat_idx = self._wallet.wallet.get_bumpfee_strategies_for_tx(tx=self._orig_tx)
+ self._bump_methods_available = [{'value': strat.name, 'text': strat.text()} for strat in strategies]
+ self.bumpMethodsAvailableChanged.emit()
+ self.bumpMethod = strategies[def_strat_idx].name
+
+ if not isinstance(self._orig_tx, PartialTransaction):
+ self._orig_tx = PartialTransaction.from_tx(self._orig_tx)
+
+ if not self._orig_tx.add_info_from_wallet_and_network(wallet=self._wallet.wallet, show_error=self._logger.error):
+ return
+
+ self.update_from_tx(self._orig_tx)
+
+ self.oldfee = self.fee
+ self.oldfeeRate = self.feeRate
+ self.update()
+
+ def tx_verified(self):
+ self._valid = False
+ self.validChanged.emit()
+ self.warning = _('Base transaction has been mined')
+
+ def tx_removed(self):
+ self._valid = False
+ self.validChanged.emit()
+ self.warning = _('Base transaction disappeared')
+
+ def update(self):
+ if not self._txid or not self._orig_tx:
+ # not initialized yet
+ return
+
+ if self._fee_policy.method == FeeMethod.FIXED:
+ fee = self._fee_policy.value
+ fee_per_kb = 1000 * fee / self._orig_tx.estimated_size()
+ else:
+ fee_per_kb = self._fee_policy.fee_per_kb(self._wallet.wallet.network)
+ if fee_per_kb is None:
+ # dynamic method and no network
+ self._logger.debug('no fee_per_kb')
+ self.warning = _('Cannot determine dynamic fees, not connected')
+ return
+
+ new_fee_rate = fee_per_kb / 1000
+ if new_fee_rate <= float(self._oldfee_rate):
+ self._valid = False
+ self.validChanged.emit()
+ self.warning = _("The new fee rate needs to be higher than the old fee rate.")
+ return
+
+ if not self._orig_tx.add_info_from_wallet_and_network(wallet=self._wallet.wallet, show_error=self._logger.error):
+ self._valid = False
+ self.validChanged.emit()
+ self.warning = _("Transaction is missing info from network")
+ return
+
+ try:
+ self._tx = self._wallet.wallet.bump_fee(
+ tx=self._orig_tx,
+ new_fee_rate=new_fee_rate,
+ strategy=BumpFeeStrategy[self._bump_method],
+ )
+ except CannotBumpFee as e:
+ self._valid = False
+ self.validChanged.emit()
+ self._logger.error(str(e))
+ self.warning = str(e)
+ return
+ else:
+ self.warning = ''
+
+ self._tx.set_rbf(self.rbf)
+
+ self.update_from_tx(self._tx)
+ self.update_fee_warning_from_tx(tx=self._tx, invoice_amt=None)
+
+ self._valid = True
+ self.validChanged.emit()
+
+ @pyqtSlot(result=str)
+ def getNewTx(self):
+ return str(self._tx)
+
+
+class QETxCanceller(TxFeeSlider, TxMonMixin):
+ _logger = get_logger(__name__)
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ self._oldfee = QEAmount()
+ self._oldfee_rate = '0'
+ self._orig_tx = None
+ self._txid = ''
+ self._rbf = True
+
+ oldfeeChanged = pyqtSignal()
+ @pyqtProperty(QVariant, notify=oldfeeChanged)
+ def oldfee(self) -> QEAmount:
+ return self._oldfee
+
+ @oldfee.setter
+ def oldfee(self, oldfee: QEAmount):
+ assert oldfee is None or isinstance(oldfee, QEAmount)
+ if self._oldfee != oldfee:
+ self._oldfee.copyFrom(oldfee)
+ self.oldfeeChanged.emit()
+
+ oldfeeRateChanged = pyqtSignal()
+ @pyqtProperty(str, notify=oldfeeRateChanged)
+ def oldfeeRate(self):
+ return self._oldfee_rate
+
+ @oldfeeRate.setter
+ def oldfeeRate(self, oldfeerate):
+ if self._oldfee_rate != oldfeerate:
+ self._oldfee_rate = oldfeerate
+ self.oldfeeRateChanged.emit()
+
+ def get_tx(self):
+ assert self._txid
+ self._orig_tx = self._wallet.wallet.db.get_transaction(self._txid)
+ assert self._orig_tx
+
+ if not isinstance(self._orig_tx, PartialTransaction):
+ self._orig_tx = PartialTransaction.from_tx(self._orig_tx)
+
+ if not self._orig_tx.add_info_from_wallet_and_network(wallet=self._wallet.wallet, show_error=self._logger.error):
+ return
+
+ self.update_from_tx(self._orig_tx)
+
+ self.oldfee = self.fee
+ self.oldfeeRate = self.feeRate
+ self.update()
+
+ def tx_verified(self):
+ self._valid = False
+ self.validChanged.emit()
+ self.warning = _('Base transaction has been mined')
+
+ def tx_removed(self):
+ self._valid = False
+ self.validChanged.emit()
+ self.warning = _('Base transaction disappeared')
+
+ def update(self):
+ if not self._txid or not self._orig_tx:
+ # not initialized yet
+ return
+
+ if self._fee_policy.method == FeeMethod.FIXED:
+ fee = self._fee_policy.value
+ fee_per_kb = 1000 * fee / self._orig_tx.estimated_size()
+ else:
+ fee_per_kb = self._fee_policy.fee_per_kb(self._wallet.wallet.network)
+ if fee_per_kb is None:
+ # dynamic method and no network
+ self._logger.debug('no fee_per_kb')
+ self.warning = _('Cannot determine dynamic fees, not connected')
+ return
+
+ new_fee_rate = fee_per_kb / 1000
+ if new_fee_rate <= float(self._oldfee_rate):
+ self._valid = False
+ self.validChanged.emit()
+ self.warning = _("The new fee rate needs to be higher than the old fee rate.")
+ return
+
+ if fee_per_kb < self._wallet.wallet.relayfee():
+ self._valid = False
+ self.validChanged.emit()
+ self._logger.warning('feerate too low for relay')
+ self.warning = messages.MSG_RELAYFEE
+ return
+
+ if not self._orig_tx.add_info_from_wallet_and_network(wallet=self._wallet.wallet, show_error=self._logger.error):
+ self._valid = False
+ self.validChanged.emit()
+ self.warning = _("Transaction is missing info from network")
+ return
+
+ try:
+ self._tx = self._wallet.wallet.dscancel(
+ tx=self._orig_tx,
+ new_fee_rate=new_fee_rate,
+ )
+ except CannotDoubleSpendTx as e:
+ self._valid = False
+ self.validChanged.emit()
+ self._logger.error(str(e))
+ self.warning = str(e)
+ return
+ else:
+ self.warning = ''
+
+ self._tx.set_rbf(self.rbf)
+
+ self.update_from_tx(self._tx)
+ self.update_fee_warning_from_tx(tx=self._tx, invoice_amt=None)
+
+ self._valid = True
+ self.validChanged.emit()
+
+ @pyqtSlot(result=str)
+ def getNewTx(self):
+ return str(self._tx)
+
+
+class QETxCpfpFeeBumper(TxFeeSlider, TxMonMixin):
+ _logger = get_logger(__name__)
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ self._input_amount = QEAmount()
+ self._output_amount = QEAmount()
+ self._total_fee = QEAmount()
+ self._total_fee_rate = 0
+ self._total_size = 0
+
+ self._parent_tx = None
+ self._new_tx = None
+ self._parent_tx_size = 0
+ self._parent_fee = 0
+ self._max_fee = 0
+ self._txid = ''
+ self._rbf = True
+
+ totalFeeChanged = pyqtSignal()
+ @pyqtProperty(QVariant, notify=totalFeeChanged)
+ def totalFee(self) -> QEAmount:
+ return self._total_fee
+
+ @totalFee.setter
+ def totalFee(self, totalfee: QEAmount):
+ assert totalfee is None or isinstance(totalfee, QEAmount)
+ if self._total_fee != totalfee:
+ self._total_fee.copyFrom(totalfee)
+ self.totalFeeChanged.emit()
+
+ totalFeeRateChanged = pyqtSignal()
+ @pyqtProperty(str, notify=totalFeeRateChanged)
+ def totalFeeRate(self):
+ return self._total_fee_rate
+
+ @totalFeeRate.setter
+ def totalFeeRate(self, totalfeerate):
+ if self._total_fee_rate != totalfeerate:
+ self._total_fee_rate = totalfeerate
+ self.totalFeeRateChanged.emit()
+
+ inputAmountChanged = pyqtSignal()
+ @pyqtProperty(QEAmount, notify=inputAmountChanged)
+ def inputAmount(self):
+ return self._input_amount
+
+ outputAmountChanged = pyqtSignal()
+ @pyqtProperty(QEAmount, notify=outputAmountChanged)
+ def outputAmount(self):
+ return self._output_amount
+
+ totalSizeChanged = pyqtSignal()
+ @pyqtProperty(int, notify=totalSizeChanged)
+ def totalSize(self):
+ return self._total_size
+
+ def get_tx(self):
+ assert self._txid
+ self._parent_tx = self._wallet.wallet.db.get_transaction(self._txid)
+ assert self._parent_tx
+
+ if isinstance(self._parent_tx, PartialTransaction):
+ self._logger.error('unexpected PartialTransaction')
+ return
+
+ self._parent_tx_size = self._parent_tx.estimated_size()
+ self._parent_fee = self._wallet.wallet.get_tx_info(self._parent_tx).fee
+
+ if self._parent_fee is None:
+ self._logger.error(_("Can't CPFP: unknown fee for parent transaction."))
+ self.warning = _("Can't CPFP: unknown fee for parent transaction.")
+ return
+
+ self._new_tx = self._wallet.wallet.cpfp(self._parent_tx, 0)
+ self._total_size = self._parent_tx_size + self._new_tx.estimated_size()
+ self.totalSizeChanged.emit()
+ self._max_fee = self._new_tx.output_value()
+ self._input_amount.satsInt = self._max_fee
+
+ self.update()
+
+ def get_child_fee_from_total_feerate(self, fee_per_kb: Optional[int]) -> Optional[int]:
+ if fee_per_kb is None:
+ return None
+ package_fee = FeePolicy.estimate_fee_for_feerate(fee_per_kb=fee_per_kb, size=self._total_size)
+ return self.get_child_fee_from_total_fee(package_fee)
+
+ def get_child_fee_from_total_fee(self, fee: int) -> int:
+ child_fee = fee - self._parent_fee
+ child_fee = min(self._max_fee, child_fee)
+ return child_fee
+
+ def tx_verified(self):
+ self._valid = False
+ self.validChanged.emit()
+ self.warning = _('Base transaction has been mined')
+
+ def tx_removed(self):
+ self._valid = False
+ self.validChanged.emit()
+ self.warning = _('Base transaction disappeared')
+
+ def update(self):
+ if not self._txid: # not initialized yet
+ return
+
+ assert self._parent_tx
+
+ self._valid = False
+ self.validChanged.emit()
+ self.warning = ''
+
+ if self._parent_fee is None:
+ self._logger.error(_("Can't CPFP: unknown fee for parent transaction."))
+ self.warning = _("Can't CPFP: unknown fee for parent transaction.")
+ return
+
+ if self._fee_policy.method == FeeMethod.FIXED:
+ fee = self.get_child_fee_from_total_fee(self._fee_policy.value)
+ else:
+ fee_per_kb = self._fee_policy.fee_per_kb(self._wallet.wallet.network)
+ if fee_per_kb is None:
+ # dynamic method and no network
+ self._logger.debug('no fee_per_kb')
+ self.warning = _('Cannot determine dynamic fees, not connected')
+ return
+ fee = self.get_child_fee_from_total_feerate(fee_per_kb=fee_per_kb)
+
+ if fee is None:
+ self._logger.warning('no fee')
+ self.warning = _('No fee')
+ return
+ if fee > self._max_fee:
+ self._logger.warning('max fee exceeded')
+ self.warning = _('Max fee exceeded')
+ return
+ min_child_fee = FeePolicy.estimate_fee_for_feerate(fee_per_kb=self._wallet.wallet.relayfee(), size=self._total_size)
+ if fee < min_child_fee:
+ self._logger.warning('feerate too low for relay')
+ self.warning = messages.MSG_RELAYFEE
+ return
+
+ comb_fee = fee + self._parent_fee
+ comb_feerate = comb_fee / self._total_size
+
+ if comb_feerate < (self._parent_fee / self._parent_tx_size):
+ self._logger.debug('combined feerate below parent tx feerate')
+ self.warning = _('Combined feerate should be greater than the parent tx feerate')
+ return
+
+ self._fee.satsInt = fee
+ self._output_amount.satsInt = self._max_fee - fee
+ self.outputAmountChanged.emit()
+
+ self._total_fee.satsInt = fee + self._parent_fee
+ self._total_fee_rate = str(quantize_feerate(comb_feerate))
+
+ try:
+ self._new_tx = self._wallet.wallet.cpfp(self._parent_tx, fee)
+ except CannotCPFP as e:
+ self._logger.error(str(e))
+ self.warning = str(e)
+ return
+
+ child_feerate = fee / self._new_tx.estimated_size()
+ self.feeRate = str(quantize_feerate(child_feerate))
+
+ self.update_inputs_from_tx(self._new_tx)
+ self.update_outputs_from_tx(self._new_tx)
+ self.update_target()
+ self.update_manual_fields()
+
+ self._valid = True
+ self.validChanged.emit()
+
+ def update_manual_fields(self):
+ if self._fee_method == FeeSlider.FSMethod.MANUAL:
+ if self._fee_policy.method == FeeMethod.FIXED:
+ self._userFeerate = self._total_fee_rate
+ self.userFeerateChanged.emit()
+ else:
+ self._userFee = self._total_fee.satsStr
+ self.userFeeChanged.emit()
+
+ @pyqtSlot(result=str)
+ def getNewTx(self):
+ return str(self._new_tx)
+
+
+class QETxSweepFinalizer(QETxFinalizer):
+ _logger = get_logger(__name__)
+
+ txinsRetrieved = pyqtSignal()
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ self._private_keys = ''
+ self._txins = None
+ self._amount = QEAmount(is_max=True)
+
+ self.txinsRetrieved.connect(self.update)
+
+ privateKeysChanged = pyqtSignal()
+ @pyqtProperty(str, notify=privateKeysChanged)
+ def privateKeys(self):
+ return self._private_keys
+
+ @privateKeys.setter
+ def privateKeys(self, private_keys):
+ if self._private_keys != private_keys:
+ self._private_keys = private_keys
+ self.update_privkeys()
+ self.privateKeysChanged.emit()
+
+ def make_sweep_tx(self):
+ address = self._wallet.wallet.get_receiving_address()
+ assert self._wallet.wallet.is_mine(address)
+ assert self._txins is not None
+
+ coins, keypairs = copy.deepcopy(self._txins)
+ outputs = [PartialTxOutput.from_address_and_value(address, value='!')]
+
+ tx = self._wallet.wallet.make_unsigned_transaction(
+ coins=coins, outputs=outputs, fee_policy=self._fee_policy, rbf=self._rbf, is_sweep=True)
+ self._logger.debug('fee: %d, inputs: %d, outputs: %d' % (tx.get_fee(), len(tx.inputs()), len(tx.outputs())))
+
+ tx.sign(keypairs)
+ return tx
+
+ def update_privkeys(self):
+ privkeys = keystore.get_private_keys(self._private_keys)
+
+ def fetch_privkeys_info():
+ try:
+ self._txins = self._wallet.wallet.network.run_from_another_thread(sweep_preparations(privkeys, self._wallet.wallet.network))
+ self._logger.debug(f'txins {self._txins!r}')
+ except NetworkException as e:
+ self.warning = _('Network error') + ': ' + str(e)
+ return
+ except UserFacingException as e:
+ self.warning = str(e)
+ return
+ self.txinsRetrieved.emit()
+
+ threading.Thread(target=fetch_privkeys_info, daemon=True).start()
+
+ def update(self):
+ if not self._wallet:
+ self._logger.debug('wallet not set, ignoring update()')
+ return
+ if not self._private_keys:
+ self._logger.debug('private keys not set, ignoring update()')
+ return
+ if self._txins is None:
+ self._logger.debug('txins not set, ignoring update()')
+ return
+
+ try:
+ # make unsigned transaction
+ tx = self.make_sweep_tx()
+ except NoDynamicFeeEstimates:
+ self.warning = _('No dynamic fee estimates available')
+ self._valid = False
+ self.validChanged.emit()
+ return
+ except NotEnoughFunds:
+ self.warning = _('Not enough funds')
+ self._valid = False
+ self.validChanged.emit()
+ return
+
+ self._tx = tx
+
+ amount = tx.output_value()
+
+ self._effectiveAmount.satsInt = amount
+ self.effectiveAmountChanged.emit()
+
+ self.update_from_tx(tx)
+ self.update_fee_warning_from_tx(tx=self._tx, invoice_amt=amount)
+
+ self._valid = True
+ self.validChanged.emit()
+
+ self.on_signed_tx(False, tx)
+
+ @pyqtSlot()
+ def send(self):
+ self._wallet.broadcast(self._tx)
+ self._wallet.wallet.set_label(self._tx.txid(), _('Sweep transaction'))
diff --git a/electrum/gui/qml/qetypes.py b/electrum/gui/qml/qetypes.py
new file mode 100644
index 000000000000..abcb445dfe81
--- /dev/null
+++ b/electrum/gui/qml/qetypes.py
@@ -0,0 +1,143 @@
+from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject
+
+from electrum.logging import get_logger
+from electrum.i18n import _
+
+
+class QEAmount(QObject):
+ """Container for bitcoin amounts that can be passed around more
+ easily between python, QML-property and QML-javascript contexts.
+ Note: millisat and sat amounts are not synchronized!
+
+ QML type 'int' in property definitions is 32 bit signed, so will overflow easily
+ on (milli)satoshi amounts! 'int' in QML-javascript seems to be larger than 32 bit, and
+ can be used to store q(u)int64 types.
+
+ QML 'quint64' and 'qint64' can be used, but be aware these will in some cases be downcast
+ by QML to 'int' (e.g. when using the property in a property binding, _even_ when a binding
+ is done between two q(u)int64 properties (at least up until Qt6.4))
+ """
+
+ _logger = get_logger(__name__)
+
+ def __init__(self, *, amount_sat: int = 0, amount_msat: int = 0, is_max: bool = False, from_invoice=None, parent=None):
+ super().__init__(parent)
+ self._amount_sat = int(amount_sat) if amount_sat is not None else None
+ self._amount_msat = int(amount_msat) if amount_msat is not None else None
+ self._is_max = is_max
+ if from_invoice:
+ inv_amt = from_invoice.get_amount_msat()
+ if inv_amt == '!':
+ self._is_max = True
+ elif inv_amt is not None:
+ self._amount_msat = int(inv_amt)
+ self._amount_sat = int(from_invoice.get_amount_sat())
+
+ valueChanged = pyqtSignal()
+
+ @pyqtProperty('qint64', notify=valueChanged)
+ def satsInt(self):
+ if self._amount_sat is None: # should normally be defined when accessing this property
+ self._logger.warning('amount_sat is undefined, returning 0')
+ return 0
+ return self._amount_sat
+
+ @satsInt.setter
+ def satsInt(self, sats):
+ if self._amount_sat != sats:
+ self._amount_sat = sats
+ self.valueChanged.emit()
+
+ @pyqtProperty('qint64', notify=valueChanged)
+ def msatsInt(self):
+ if self._amount_msat is None: # should normally be defined when accessing this property
+ self._logger.warning('amount_msat is undefined, returning 0')
+ return 0
+ return self._amount_msat
+
+ @msatsInt.setter
+ def msatsInt(self, msats):
+ if self._amount_msat != msats:
+ self._amount_msat = msats
+ self.valueChanged.emit()
+
+ @pyqtProperty(str, notify=valueChanged)
+ def satsStr(self):
+ return str(self._amount_sat)
+
+ @pyqtProperty(str, notify=valueChanged)
+ def msatsStr(self):
+ return str(self._amount_msat)
+
+ @pyqtProperty(bool, notify=valueChanged)
+ def isMax(self):
+ return self._is_max
+
+ @isMax.setter
+ def isMax(self, ismax):
+ if self._is_max != ismax:
+ self._is_max = ismax
+ self.valueChanged.emit()
+
+ @pyqtProperty(bool, notify=valueChanged)
+ def isEmpty(self):
+ return not(self._is_max or self._amount_sat or self._amount_msat)
+
+ @pyqtSlot()
+ def clear(self):
+ self._amount_sat = 0
+ self._amount_msat = 0
+ self._is_max = False
+ self.valueChanged.emit()
+
+ @pyqtSlot('QVariant')
+ def copyFrom(self, amount):
+ if not amount:
+ self._logger.warning('copyFrom with None argument. assuming 0') # TODO
+ amount = QEAmount()
+ self.satsInt = amount.satsInt
+ self.msatsInt = amount.msatsInt
+ self.isMax = amount.isMax
+
+ def __eq__(self, other):
+ if isinstance(other, QEAmount):
+ return self._amount_sat == other._amount_sat and self._amount_msat == other._amount_msat and self._is_max == other._is_max
+ elif isinstance(other, int):
+ return self._amount_sat == other
+ elif isinstance(other, str):
+ return self.satsStr == other
+
+ return False
+
+ def __str__(self):
+ s = _('Amount')
+ if self._is_max:
+ return '%s(MAX)' % s
+ return '%s(sats=%d, msats=%d)' % (s, self._amount_sat, self._amount_msat)
+
+ def __repr__(self):
+ return f""
+
+
+class QEBytes(QObject):
+ def __init__(self, data: bytes = None, *, parent=None):
+ super().__init__(parent)
+ self.data = data
+
+ @property
+ def data(self):
+ return self._data
+
+ @data.setter
+ def data(self, _data):
+ self._data = _data
+
+ @pyqtProperty(bool)
+ def isEmpty(self):
+ return self._data is None or self._data == bytes()
+
+ def __str__(self):
+ return f'{self._data}'
+
+ def __repr__(self):
+ return f""
diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py
new file mode 100644
index 000000000000..c51d9bb5c706
--- /dev/null
+++ b/electrum/gui/qml/qewallet.py
@@ -0,0 +1,862 @@
+import asyncio
+import base64
+import queue
+import threading
+import time
+from typing import TYPE_CHECKING, Callable, Optional, Any, Tuple
+from functools import partial
+
+from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QTimer
+
+from electrum.i18n import _
+from electrum.invoices import InvoiceError, PR_PAID, PR_BROADCASTING, PR_BROADCAST
+from electrum.logging import get_logger
+from electrum.network import TxBroadcastError, BestEffortRequestFailed
+from electrum.transaction import PartialTransaction, Transaction
+from electrum.util import (
+ InvalidPassword, event_listener, AddTransactionException, get_asyncio_loop, NotEnoughFunds, NoDynamicFeeEstimates
+)
+from electrum.lnutil import MIN_FUNDING_SAT
+from electrum.plugin import run_hook
+from electrum.wallet import Multisig_Wallet
+from electrum.crypto import pw_decode_with_version_and_mac
+from electrum.fee_policy import FeePolicy, FixedFeePolicy
+
+from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
+
+from .auth import AuthMixin, auth_protect
+from .qeaddresslistmodel import QEAddressCoinListModel
+from .qechannellistmodel import QEChannelListModel
+from .qeinvoicelistmodel import QEInvoiceListModel, QERequestListModel
+from .qetransactionlistmodel import QETransactionListModel
+from .qetypes import QEAmount
+
+if TYPE_CHECKING:
+ from electrum.wallet import Abstract_Wallet
+ from electrum.invoices import Invoice
+
+
+class QEWallet(AuthMixin, QObject, QtEventListener):
+ __instances = []
+
+ # this factory method should be used to instantiate QEWallet
+ # so we have only one QEWallet for each electrum.wallet
+ @classmethod
+ def getInstanceFor(cls, wallet):
+ for i in cls.__instances:
+ if i.wallet == wallet:
+ return i
+ i = QEWallet(wallet)
+ cls.__instances.append(i)
+ return i
+
+ _logger = get_logger(__name__)
+
+ # emitted when wallet wants to display a user notification
+ # actual presentation should be handled on app or window level
+ userNotify = pyqtSignal(object, object)
+
+ # shared signal for many static wallet properties
+ dataChanged = pyqtSignal()
+
+ balanceChanged = pyqtSignal()
+ requestStatusChanged = pyqtSignal([str, int], arguments=['key', 'status'])
+ requestCreateSuccess = pyqtSignal([str], arguments=['key'])
+ requestCreateError = pyqtSignal([str], arguments=['error'])
+ invoiceStatusChanged = pyqtSignal([str, int], arguments=['key', 'status'])
+ invoiceCreateSuccess = pyqtSignal()
+ invoiceCreateError = pyqtSignal([str, str], arguments=['code', 'error'])
+ paymentAuthRejected = pyqtSignal()
+ paymentSucceeded = pyqtSignal([str], arguments=['key'])
+ paymentFailed = pyqtSignal([str, str], arguments=['key', 'reason'])
+ requestNewPassword = pyqtSignal()
+ broadcastSucceeded = pyqtSignal([str], arguments=['txid'])
+ broadcastFailed = pyqtSignal([str, str, str], arguments=['txid', 'code', 'reason'])
+ saveTxSuccess = pyqtSignal([str], arguments=['txid'])
+ saveTxError = pyqtSignal([str, str, str], arguments=['txid', 'code', 'message'])
+ importChannelBackupFailed = pyqtSignal([str], arguments=['message'])
+ otpRequested = pyqtSignal()
+ otpSuccess = pyqtSignal()
+ otpFailed = pyqtSignal([str, str], arguments=['code', 'message'])
+ peersUpdated = pyqtSignal()
+ seedRetrieved = pyqtSignal()
+ messageSigned = pyqtSignal([str], arguments=['signature'])
+
+ _network_signal = pyqtSignal(str, object)
+
+ def __init__(self, wallet: 'Abstract_Wallet', parent=None):
+ super().__init__(parent)
+ self.wallet = wallet
+
+ self._logger = get_logger(f'{__name__}.[{wallet}]')
+
+ self._synchronizing = False
+ self._synchronizing_progress = ''
+
+ self._historyModel = None
+ self._addressCoinModel = None
+ self._requestModel = None
+ self._invoiceModel = None
+ self._channelModel = None
+
+ self._lightningbalance = QEAmount()
+ self._confirmedbalance = QEAmount()
+ self._unconfirmedbalance = QEAmount()
+ self._frozenbalance = QEAmount()
+ self._totalbalance = QEAmount()
+ self._lightningcanreceive = QEAmount()
+ self._minchannelfunding = QEAmount(amount_sat=int(MIN_FUNDING_SAT))
+ self._lightningcansend = QEAmount()
+ self._lightningbalancefrozen = QEAmount()
+
+ self._seed = ''
+ self._seed_passphrase = ''
+
+ self.tx_notification_queue = queue.Queue()
+ self.tx_notification_last_time = 0
+
+ self.notification_timer = QTimer(self)
+ self.notification_timer.setSingleShot(False)
+ self.notification_timer.setInterval(500) # msec
+ self.notification_timer.timeout.connect(self.notify_transactions)
+
+ self.sync_progress_timer = QTimer(self)
+ self.sync_progress_timer.setSingleShot(False)
+ self.sync_progress_timer.setInterval(2000)
+ self.sync_progress_timer.timeout.connect(self.update_sync_progress)
+
+ # post-construction init in GUI thread
+ # QMetaObject.invokeMethod(self, 'qt_init', Qt.QueuedConnection)
+
+ # 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.register_callbacks()
+ self.destroyed.connect(lambda: self.on_destroy())
+ self.synchronizing = not wallet.is_up_to_date()
+
+ synchronizingChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=synchronizingChanged)
+ def synchronizing(self):
+ return self._synchronizing
+
+ @synchronizing.setter
+ def synchronizing(self, synchronizing):
+ if self._synchronizing != synchronizing:
+ self._logger.debug(f'SYNC {self._synchronizing} -> {synchronizing}')
+ self._synchronizing = synchronizing
+ self.synchronizingChanged.emit()
+ if synchronizing:
+ if not self.sync_progress_timer.isActive():
+ self.update_sync_progress()
+ self.sync_progress_timer.start()
+ else:
+ self.sync_progress_timer.stop()
+
+ synchronizingProgressChanged = pyqtSignal()
+ @pyqtProperty(str, notify=synchronizingProgressChanged)
+ def synchronizingProgress(self):
+ return self._synchronizing_progress
+
+ @synchronizingProgress.setter
+ def synchronizingProgress(self, progress):
+ if self._synchronizing_progress != progress:
+ self._synchronizing_progress = progress
+ self._logger.info(progress)
+ self.synchronizingProgressChanged.emit()
+
+ multipleChangeChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=multipleChangeChanged)
+ def multipleChange(self):
+ return self.wallet.multiple_change
+
+ @multipleChange.setter
+ def multipleChange(self, multiple_change):
+ if self.wallet.multiple_change != multiple_change:
+ self.wallet.multiple_change = multiple_change
+ self.wallet.db.put('multiple_change', self.wallet.multiple_change)
+ self.multipleChangeChanged.emit()
+
+ @qt_event_listener
+ def on_event_request_status(self, wallet, key, status):
+ if wallet == self.wallet:
+ self._logger.debug('request status %d for key %s' % (status, key))
+ self.requestStatusChanged.emit(key, status)
+ if status == PR_PAID:
+ # might be new incoming LN payment, update history
+ # TODO: only update if it was paid over lightning,
+ # and even then, we can probably just add the payment instead
+ # of recreating the whole history (expensive)
+ self.historyModel.initModel(True)
+
+ @event_listener
+ def on_event_invoice_status(self, wallet, key, status):
+ if wallet == self.wallet:
+ self._logger.debug(f'invoice status update for key {key} to {status}')
+ self.invoiceStatusChanged.emit(key, status)
+
+ @qt_event_listener
+ def on_event_new_transaction(self, wallet: 'Abstract_Wallet', tx: Transaction):
+ if wallet == self.wallet:
+ self._logger.info(f'new transaction {tx.txid()}')
+ self.add_tx_notification(tx)
+ self.addressCoinModel.setDirty()
+ self.historyModel.setDirty() # assuming wallet.is_up_to_date triggers after
+ self.balanceChanged.emit()
+
+ @qt_event_listener
+ def on_event_adb_tx_height_changed(self, adb, txid, old_height, new_height):
+ if adb == self.wallet.adb:
+ self._logger.info(f'tx_height_changed {txid}. {old_height} -> {new_height}')
+ self.historyModel.setDirty() # assuming wallet.is_up_to_date triggers after
+
+ @qt_event_listener
+ def on_event_removed_transaction(self, wallet, tx):
+ # NOTE: this event only triggers once, only for the first deleted tx, when for imported wallets an address
+ # is deleted along with multiple associated txs
+ if wallet == self.wallet:
+ self._logger.info(f'removed transaction {tx.txid()}')
+ self.addressCoinModel.setDirty()
+ self.historyModel.setDirty()
+ self.balanceChanged.emit()
+
+ @qt_event_listener
+ def on_event_wallet_updated(self, wallet):
+ if wallet == self.wallet:
+ self._logger.debug('wallet_updated')
+ self.balanceChanged.emit()
+ self.historyModel.setDirty()
+ self.synchronizing = not wallet.is_up_to_date()
+ if not self.synchronizing:
+ self.historyModel.initModel() # refresh if dirty
+
+ @event_listener
+ def on_event_channel(self, wallet, channel):
+ if wallet == self.wallet:
+ self.balanceChanged.emit()
+ self.peersUpdated.emit()
+
+ @event_listener
+ def on_event_channels_updated(self, wallet):
+ if wallet == self.wallet:
+ self.balanceChanged.emit()
+ self.peersUpdated.emit()
+
+ @qt_event_listener
+ def on_event_payment_succeeded(self, wallet, key):
+ if wallet == self.wallet:
+ self.paymentSucceeded.emit(key)
+ self.historyModel.initModel(True) # TODO: be less dramatic
+
+ @event_listener
+ def on_event_payment_failed(self, wallet, key, reason):
+ if wallet == self.wallet:
+ self.paymentFailed.emit(key, reason)
+
+ def on_destroy(self):
+ self.unregister_callbacks()
+
+ def add_tx_notification(self, tx: Transaction):
+ self._logger.debug('new transaction event')
+ self.tx_notification_queue.put(tx)
+ if not self.notification_timer.isActive():
+ self._logger.debug('starting wallet notification timer')
+ self.notification_timer.start()
+
+ def notify_transactions(self):
+ if self.tx_notification_queue.qsize() == 0:
+ self._logger.debug('queue empty, stopping wallet notification timer')
+ self.notification_timer.stop()
+ return
+ if not self.wallet.is_up_to_date():
+ return # no notifications while syncing
+ now = time.time()
+ rate_limit = 20 # seconds
+ if self.tx_notification_last_time + rate_limit > now:
+ return
+ self.tx_notification_last_time = now
+ self._logger.info("Notifying app about new transactions")
+ txns = []
+ while True:
+ try:
+ txns.append(self.tx_notification_queue.get_nowait())
+ except queue.Empty:
+ break
+
+ for notification in self.wallet.get_user_notifications_for_new_txns(txns):
+ self.userNotify.emit(self.wallet, notification)
+
+ def update_sync_progress(self):
+ if self.wallet.network and self.wallet.network.is_connected():
+ num_sent, num_answered = self.wallet.adb.get_history_sync_state_details()
+ self.synchronizingProgress = \
+ ("{} ({}/{})".format(_("Synchronizing..."), num_answered, num_sent))
+
+ historyModelChanged = pyqtSignal()
+ @pyqtProperty(QETransactionListModel, notify=historyModelChanged)
+ def historyModel(self):
+ if self._historyModel is None:
+ self._historyModel = QETransactionListModel(self.wallet)
+ return self._historyModel
+
+ addressCoinModelChanged = pyqtSignal()
+ @pyqtProperty(QEAddressCoinListModel, notify=addressCoinModelChanged)
+ def addressCoinModel(self):
+ if self._addressCoinModel is None:
+ self._addressCoinModel = QEAddressCoinListModel(self.wallet)
+ return self._addressCoinModel
+
+ requestModelChanged = pyqtSignal()
+ @pyqtProperty(QERequestListModel, notify=requestModelChanged)
+ def requestModel(self):
+ if self._requestModel is None:
+ self._requestModel = QERequestListModel(self.wallet)
+ return self._requestModel
+
+ invoiceModelChanged = pyqtSignal()
+ @pyqtProperty(QEInvoiceListModel, notify=invoiceModelChanged)
+ def invoiceModel(self):
+ if self._invoiceModel is None:
+ self._invoiceModel = QEInvoiceListModel(self.wallet)
+ return self._invoiceModel
+
+ channelModelChanged = pyqtSignal()
+ @pyqtProperty(QEChannelListModel, notify=channelModelChanged)
+ def channelModel(self):
+ if self._channelModel is None:
+ self._channelModel = QEChannelListModel(self.wallet)
+ return self._channelModel
+
+ nameChanged = pyqtSignal()
+ @pyqtProperty(str, notify=nameChanged)
+ def name(self):
+ return self.wallet.basename()
+
+ isLightningChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=isLightningChanged)
+ def isLightning(self):
+ return bool(self.wallet.lnworker)
+
+ billingInfoChanged = pyqtSignal()
+ @pyqtProperty('QVariantMap', notify=billingInfoChanged)
+ def billingInfo(self):
+ if self.wallet.wallet_type != '2fa':
+ return {}
+ return self.wallet.billing_info if self.wallet.billing_info is not None else {}
+
+ @pyqtProperty(bool, notify=dataChanged)
+ def canHaveLightning(self):
+ return self.wallet.can_have_lightning()
+
+ @pyqtProperty(str, notify=dataChanged)
+ def walletType(self):
+ return self.wallet.wallet_type
+
+ @pyqtProperty(bool, notify=dataChanged)
+ def isMultisig(self):
+ return isinstance(self.wallet, Multisig_Wallet)
+
+ @pyqtProperty(bool, notify=dataChanged)
+ def hasSeed(self):
+ return self.wallet.has_seed()
+
+ @pyqtProperty(str, notify=dataChanged)
+ def seed(self):
+ return self._seed
+
+ @pyqtProperty(str, notify=dataChanged)
+ def seedPassphrase(self):
+ return self._seed_passphrase
+
+ @pyqtProperty(str, notify=dataChanged)
+ def txinType(self):
+ if self.wallet.wallet_type == 'imported':
+ return self.wallet.txin_type
+ return self.wallet.get_txin_type(self.wallet.dummy_address())
+
+ @pyqtProperty(str, notify=dataChanged)
+ def seedType(self):
+ return self.wallet.get_seed_type()
+
+ @pyqtProperty(bool, notify=dataChanged)
+ def isWatchOnly(self):
+ return self.wallet.is_watching_only()
+
+ @pyqtProperty(bool, notify=dataChanged)
+ def isDeterministic(self):
+ return self.wallet.is_deterministic()
+
+ @pyqtProperty(bool, notify=dataChanged)
+ def isEncrypted(self):
+ return self.wallet.storage.is_encrypted()
+
+ @pyqtProperty(bool, notify=dataChanged)
+ def isHardware(self):
+ return self.wallet.storage.is_encrypted_with_hw_device()
+
+ @pyqtProperty('QVariantList', notify=dataChanged)
+ def keystores(self):
+ result = []
+ for k in self.wallet.get_keystores():
+ result.append({
+ 'keystore_type': k.type,
+ 'watch_only': k.is_watching_only(),
+ 'derivation_prefix': (k.get_derivation_prefix() if k.is_deterministic() else '') or '',
+ 'master_pubkey': (k.get_master_public_key() if k.is_deterministic() else '') or '',
+ 'fingerprint': (k.get_root_fingerprint() if k.is_deterministic() else '') or '',
+ 'num_imported': len(k.keypairs) if k.can_import() else 0,
+ })
+ return result
+
+ @pyqtProperty(str, notify=dataChanged)
+ def lightningNodePubkey(self):
+ return self.wallet.lnworker.node_keypair.pubkey.hex() if self.wallet.lnworker else ''
+
+ @pyqtProperty(bool, notify=dataChanged)
+ def lightningHasDeterministicNodeId(self):
+ return self.wallet.lnworker.has_deterministic_node_id() if self.wallet.lnworker else False
+
+ @pyqtProperty(str, notify=dataChanged)
+ def derivationPrefix(self):
+ keystores = self.wallet.get_keystores()
+ if len(keystores) > 1:
+ self._logger.debug('multiple keystores not supported yet')
+ if len(keystores) == 0:
+ self._logger.debug('no keystore')
+ return ''
+ if not self.isDeterministic:
+ return ''
+ return keystores[0].get_derivation_prefix()
+
+ @pyqtProperty(str, notify=dataChanged)
+ def masterPubkey(self):
+ return self.wallet.get_master_public_key()
+
+ @pyqtProperty(bool, notify=dataChanged)
+ def canSignWithoutServer(self):
+ return self.wallet.can_sign_without_server() if self.wallet.wallet_type == '2fa' else True
+
+ @pyqtProperty(bool, notify=dataChanged)
+ def canSignWithoutCosigner(self):
+ if isinstance(self.wallet, Multisig_Wallet):
+ if self.wallet.wallet_type == '2fa': # 2fa is multisig, but it handles cosigning itself
+ return True
+ return self.wallet.m == 1
+ return True
+
+ @pyqtProperty(bool, notify=dataChanged)
+ def canSignMessage(self):
+ return not isinstance(self.wallet, Multisig_Wallet) and not self.wallet.is_watching_only()
+
+ canGetZeroconfChannelChanged = pyqtSignal()
+ @pyqtProperty(bool, notify=canGetZeroconfChannelChanged)
+ def canGetZeroconfChannel(self) -> bool:
+ return self.wallet.lnworker and self.wallet.lnworker.can_get_zeroconf_channel()
+
+ @pyqtProperty(QEAmount, notify=balanceChanged)
+ def frozenBalance(self):
+ c, u, x = self.wallet.get_frozen_balance()
+ self._frozenbalance.satsInt = c+x
+ return self._frozenbalance
+
+ @pyqtProperty(QEAmount, notify=balanceChanged)
+ def unconfirmedBalance(self):
+ self._unconfirmedbalance.satsInt = self.wallet.get_balance()[1]
+ return self._unconfirmedbalance
+
+ @pyqtProperty(QEAmount, notify=balanceChanged)
+ def confirmedBalance(self):
+ c, u, x = self.wallet.get_balance()
+ self._confirmedbalance.satsInt = c+x
+ return self._confirmedbalance
+
+ @pyqtProperty(QEAmount, notify=balanceChanged)
+ def lightningBalance(self):
+ if self.isLightning:
+ self._lightningbalance.satsInt = int(self.wallet.lnworker.get_balance())
+ return self._lightningbalance
+
+ @pyqtProperty(QEAmount, notify=balanceChanged)
+ def lightningBalanceFrozen(self):
+ if self.isLightning:
+ self._lightningbalancefrozen.satsInt = int(self.wallet.lnworker.get_balance(frozen=True))
+ return self._lightningbalancefrozen
+
+ @pyqtProperty(QEAmount, notify=balanceChanged)
+ def totalBalance(self):
+ total = self.confirmedBalance.satsInt + self.lightningBalance.satsInt
+ self._totalbalance.satsInt = total
+ return self._totalbalance
+
+ @pyqtProperty(QEAmount, notify=balanceChanged)
+ def lightningCanSend(self):
+ if self.isLightning:
+ self._lightningcansend.satsInt = int(self.wallet.lnworker.num_sats_can_send())
+ return self._lightningcansend
+
+ @pyqtProperty(QEAmount, notify=balanceChanged)
+ def lightningCanReceive(self):
+ if self.isLightning:
+ self._lightningcanreceive.satsInt = int(self.wallet.lnworker.num_sats_can_receive())
+ return self._lightningcanreceive
+
+ @pyqtProperty(bool, notify=balanceChanged)
+ def isLowReserve(self):
+ return self.wallet.is_low_reserve()
+
+ @pyqtProperty(QEAmount, notify=dataChanged)
+ def minChannelFunding(self):
+ return self._minchannelfunding
+
+ @pyqtProperty(int, notify=peersUpdated)
+ def lightningNumPeers(self):
+ if self.isLightning:
+ return self.wallet.lnworker.lnpeermgr.num_peers()
+ return 0
+
+ @pyqtSlot()
+ def enableLightning(self):
+ self.wallet.init_lightning(password=self.password)
+ self.isLightningChanged.emit()
+ self.dataChanged.emit()
+
+ @auth_protect(message=_('Sign and send on-chain transaction?'))
+ def sign_and_broadcast(self, tx, *,
+ on_success: Callable[[Transaction], None] = None,
+ on_failure: Callable[[Optional[Any]], None] = None) -> None:
+ self.do_sign(tx, True, on_success, on_failure)
+
+ @auth_protect(message=_('Sign on-chain transaction?'))
+ def sign(self, tx, *,
+ on_success: Callable[[Transaction], None] = None,
+ on_failure: Callable[[Optional[Any]], None] = None) -> None:
+ self.do_sign(tx, False, on_success, on_failure)
+
+ def do_sign(self, tx, broadcast, on_success: Callable[[Transaction], None] = None, on_failure: Callable[[Optional[Any]], None] = None):
+ # tc_sign_wrapper is only used by 2fa. don't pass on_failure handler, it is handled via otpFailed signal
+ sign_hook = run_hook('tc_sign_wrapper', self.wallet, tx,
+ partial(self.on_sign_complete, broadcast, on_success),
+ partial(self.on_sign_failed, None))
+ try:
+ # ignore_warnings=True, because UI checks and asks user confirmation itself
+ tx = self.wallet.sign_transaction(tx, self.password, ignore_warnings=True)
+ except BaseException as e:
+ self._logger.error(f'{e!r}')
+ if on_failure:
+ on_failure(str(e))
+ return
+
+ if tx is None:
+ self._logger.info('did not sign')
+ if on_failure:
+ on_failure()
+ return
+
+ if sign_hook:
+ self._logger.debug('plugin needs to sign tx too')
+ sign_hook(tx)
+ return
+
+ txid = tx.txid()
+ self._logger.debug(f'do_sign(), txid={txid}')
+
+ if not tx.is_complete():
+ self._logger.debug('tx not complete')
+ broadcast = False
+
+ if broadcast:
+ self.broadcast(tx)
+ else:
+ # not broadcasted, so refresh history here
+ self.historyModel.initModel(True)
+
+ if on_success:
+ on_success(tx)
+
+ # this assumes a 2fa wallet, but there are no other tc_sign_wrapper hooks, so that's ok
+ def on_sign_complete(self, broadcast, cb: Callable[[Transaction], None] = None, tx: Transaction = None):
+ self.otpSuccess.emit()
+ if cb:
+ cb(tx)
+ if broadcast:
+ self.broadcast(tx)
+
+ # this assumes a 2fa wallet, but there are no other tc_sign_wrapper hooks, so that's ok
+ def on_sign_failed(self, cb: Callable[[], None] = None, error: str = None):
+ self.otpFailed.emit('error', error)
+ if cb:
+ cb()
+
+ def request_otp(self, on_submit):
+ self._otp_on_submit = on_submit
+ self.otpRequested.emit()
+
+ @pyqtSlot(str)
+ def submitOtp(self, otp):
+ def submit_otp_task():
+ self._otp_on_submit(otp)
+ threading.Thread(target=submit_otp_task, daemon=True).start()
+
+ def broadcast(self, tx):
+ assert tx.is_complete()
+
+ async def broadcast_coro():
+ self.wallet.set_broadcasting(tx, broadcasting_status=PR_BROADCASTING)
+ try:
+ self._logger.info('broadcasting tx in coroutine')
+ await self.wallet.network.broadcast_transaction(tx)
+ except TxBroadcastError as e:
+ self._logger.error(repr(e))
+ self.broadcastFailed.emit(tx.txid(), '', e.get_message_for_gui())
+ except BestEffortRequestFailed as e:
+ self._logger.error(repr(e))
+ self.broadcastFailed.emit(tx.txid(), '', repr(e))
+ except Exception:
+ self._logger.exception("failed to broadcast tx")
+ else:
+ self._logger.info('broadcast success')
+ self.broadcastSucceeded.emit(tx.txid())
+ self.historyModel.requestRefresh.emit() # via qt thread
+ finally:
+ self.wallet.set_broadcasting(tx, broadcasting_status=None)
+
+ asyncio.run_coroutine_threadsafe(broadcast_coro(), get_asyncio_loop())
+
+ # TODO: properly catch server side errors, e.g. bad-txns-inputs-missingorspent
+
+ def save_tx(self, tx: 'PartialTransaction') -> bool:
+ assert tx
+
+ try:
+ if not self.wallet.adb.add_transaction(tx):
+ self.saveTxError.emit(tx.txid(), 'conflict',
+ _("Transaction could not be saved.") + "\n" + _("It conflicts with current history."))
+ return False
+ self.wallet.save_db()
+ self.saveTxSuccess.emit(tx.txid())
+ self.historyModel.initModel(True)
+ return True
+ except AddTransactionException as e:
+ self.saveTxError.emit(tx.txid(), 'error', str(e))
+ return False
+
+ def ln_auth_rejected(self):
+ self.paymentAuthRejected.emit()
+
+ @auth_protect(message=_('Pay lightning invoice?'), reject='ln_auth_rejected')
+ def pay_lightning_invoice(self, invoice: 'Invoice', amount_msat: int = None):
+ # at this point, the user confirmed the payment, potentially with an override amount.
+ # we save the invoice with the override amount if there was no amount defined in the invoice.
+ # (this is similar to what the desktop client does)
+ #
+ # Note: amount_msat can be greater than the invoice-specified amount. This is validated and handled
+ # in lnworker.pay_invoice()
+ if amount_msat is not None:
+ assert type(amount_msat) is int
+ if invoice.get_amount_msat() is None:
+ invoice.set_amount_msat(amount_msat)
+ else:
+ amount_msat = invoice.get_amount_msat()
+
+ self.wallet.save_invoice(invoice)
+ if self._invoiceModel:
+ self._invoiceModel.initModel()
+
+ async def pay_coro():
+ try:
+ await self.wallet.lnworker.pay_invoice(invoice, amount_msat=amount_msat)
+ except Exception as e:
+ self._logger.error(f'pay_invoice failed! {e!r}')
+ self.paymentFailed.emit(invoice.get_id(), str(e))
+
+ asyncio.run_coroutine_threadsafe(pay_coro(), get_asyncio_loop())
+
+ @pyqtSlot()
+ def deleteExpiredRequests(self):
+ keys = self.wallet.delete_expired_requests()
+ for key in keys:
+ self.requestModel.delete_invoice(key)
+
+ @pyqtSlot(QEAmount, str, int)
+ @pyqtSlot(QEAmount, str, int, bool)
+ @pyqtSlot(QEAmount, str, int, bool, bool)
+ @pyqtSlot(QEAmount, str, int, bool, bool, bool)
+ def createRequest(self, amount: QEAmount, message: str, expiration: int, lightning: bool = False, reuse_address: bool = False):
+ self.deleteExpiredRequests()
+ try:
+ amount = amount.satsInt
+ if not lightning:
+ addr = self.wallet.get_unused_address()
+ if addr is None:
+ if reuse_address:
+ addr = self.wallet.get_receiving_address()
+ else:
+ msg = [
+ _('No address available.'),
+ _('All your addresses are used in pending requests.'),
+ _('To see the list, press and hold the Receive button.'),
+ ]
+ self.requestCreateError.emit(' '.join(msg))
+ return
+ else:
+ addr = None
+
+ key = self.wallet.create_request(amount, message, expiration, addr)
+ except InvoiceError as e:
+ self.requestCreateError.emit(_('Error creating payment request') + ':\n' + str(e))
+ return
+
+ assert key is not None
+ self._logger.debug(f'created request with key {key} addr {addr}')
+ self.addressCoinModel.setDirty()
+ self.requestModel.add_invoice(self.wallet.get_request(key))
+ self.requestCreateSuccess.emit(key)
+
+ @pyqtSlot(str)
+ def deleteRequest(self, key: str):
+ self._logger.debug('delete req %s' % key)
+ self.wallet.delete_request(key)
+ self.requestModel.delete_invoice(key)
+
+ @pyqtSlot(str)
+ def deleteInvoice(self, key: str):
+ self._logger.debug('delete inv %s' % key)
+ self.wallet.delete_invoice(key)
+ self.invoiceModel.delete_invoice(key)
+
+ @pyqtSlot(str, result=bool)
+ def verifyPassword(self, password):
+ if not self.wallet.has_password():
+ return not bool(password)
+ try:
+ self.wallet.check_password(password)
+ return True
+ except InvalidPassword as e:
+ return False
+
+ @pyqtSlot(str, result=bool)
+ def setPassword(self, password):
+ if password == '':
+ password = None
+
+ storage = self.wallet.storage
+
+ # HW wallet not supported yet
+ if storage.is_encrypted_with_hw_device():
+ return False
+
+ current_password = self.password if self.password != '' else None
+
+ try:
+ self._logger.info('setting new password')
+ self.wallet.update_password(current_password, password, encrypt_storage=True)
+ # restore the invariant that all loaded wallets in qml must be unlocked:
+ self.wallet.unlock(password)
+ return True
+ except InvalidPassword as e:
+ self._logger.exception(repr(e))
+ return False
+
+ @property
+ def password(self):
+ return self.wallet.get_unlocked_password()
+
+ @pyqtSlot(str)
+ def importAddresses(self, addresslist):
+ self.wallet.import_addresses(addresslist.split())
+ if self._addressCoinModel:
+ self._addressCoinModel.setDirty()
+ self.dataChanged.emit()
+
+ @pyqtSlot(str)
+ def importPrivateKeys(self, keyslist):
+ self.wallet.import_private_keys(keyslist.split(), self.password)
+ if self._addressCoinModel:
+ self._addressCoinModel.setDirty()
+ self.dataChanged.emit()
+
+ @pyqtSlot(str)
+ def importChannelBackup(self, backup_str):
+ try:
+ self.wallet.lnworker.import_channel_backup(backup_str)
+ except Exception as e:
+ self._logger.debug(f'could not import channel backup: {repr(e)}')
+ self.importChannelBackupFailed.emit(f'Failed to import backup:\n\n{str(e)}')
+
+ @pyqtSlot(str, result=bool)
+ def isValidChannelBackup(self, backup_str):
+ try:
+ assert backup_str.startswith('channel_backup:')
+ encrypted = backup_str[15:]
+ xpub = self.wallet.get_fingerprint()
+ decrypted = pw_decode_with_version_and_mac(encrypted, xpub)
+ return True
+ except Exception:
+ return False
+
+ @pyqtSlot()
+ def requestShowSeed(self):
+ self.retrieve_seed()
+
+ @auth_protect(method='wallet')
+ def retrieve_seed(self):
+ try:
+ self._seed = self.wallet.get_seed(self.password)
+ self._seed_passphrase = self.wallet.keystore.get_passphrase(self.password)
+ self.seedRetrieved.emit()
+ except Exception:
+ self._seed = ''
+ self._seed_passphrase = ''
+
+ self.dataChanged.emit()
+
+ @pyqtSlot(str, result='QVariantList')
+ def getSerializedTx(self, txid):
+ tx = self.wallet.db.get_transaction(txid)
+ txqr = tx.to_qr_data()
+ return [str(tx), txqr[0], txqr[1]]
+
+ @pyqtSlot(result='QVariantMap')
+ def getBalancesForPiechart(self):
+ p_bal = self.wallet.get_balances_for_piechart()
+ return {
+ 'confirmed': p_bal.confirmed,
+ 'unconfirmed': p_bal.unconfirmed,
+ 'unmatured': p_bal.unmatured,
+ 'frozen': p_bal.frozen,
+ 'lightning': int(p_bal.lightning),
+ 'f_lightning': int(p_bal.lightning_frozen),
+ 'total': int(p_bal.total())
+ }
+
+ @pyqtSlot(str, result=bool)
+ def isAddressMine(self, addr):
+ return self.wallet.is_mine(addr)
+
+ @pyqtSlot(str, str)
+ @auth_protect(message=_("Sign message?"))
+ def signMessage(self, address, message):
+ sig = self.wallet.sign_message(address, message, self.password)
+ result = base64.b64encode(sig).decode('ascii')
+ self.messageSigned.emit(result)
+
+ def determine_max(self, *, mktx: Callable[[FeePolicy], PartialTransaction]) -> Tuple[Optional[int], Optional[str]]:
+ # TODO: merge with SendTab.spend_max() and move to backend wallet
+ amount = message = None
+ try:
+ try:
+ fee_policy = FeePolicy(self.wallet.config.FEE_POLICY)
+ tx = mktx(fee_policy)
+ except (NotEnoughFunds, NoDynamicFeeEstimates) as e:
+ # Check if we had enough funds excluding fees,
+ # if so, still provide opportunity to set lower fees.
+ fee_policy = FixedFeePolicy(0)
+ tx = mktx(fee_policy)
+ amount = tx.output_value()
+ except NotEnoughFunds as e:
+ self._logger.debug(str(e))
+ message = self.wallet.get_text_not_enough_funds_mentioning_frozen(for_amount='!')
+
+ return amount, message
diff --git a/electrum/gui/qml/qewizard.py b/electrum/gui/qml/qewizard.py
new file mode 100644
index 000000000000..3bd3f33ece62
--- /dev/null
+++ b/electrum/gui/qml/qewizard.py
@@ -0,0 +1,180 @@
+import os
+from typing import TYPE_CHECKING
+
+from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject
+
+from electrum.base_crash_reporter import send_exception_to_crash_reporter
+from electrum.logging import get_logger
+from electrum import mnemonic
+from electrum.wizard import NewWalletWizard, ServerConnectWizard, TermsOfUseWizard
+from electrum.util import UserFacingException
+from electrum.gui import messages
+
+if TYPE_CHECKING:
+ from electrum.gui.qml.qedaemon import QEDaemon
+ from electrum.plugin import Plugins
+
+
+class QEAbstractWizard(QObject):
+ """ Concrete subclasses of QEAbstractWizard must also inherit from a concrete AbstractWizard subclass.
+ QEAbstractWizard forms the base for all QML GUI based wizards, while AbstractWizard defines
+ the base for non-gui wizard flow navigation functionality.
+ """
+ _logger = get_logger(__name__)
+
+ def __init__(self, parent=None):
+ QObject.__init__(self, parent)
+
+ @pyqtSlot(result=str)
+ def startWizard(self):
+ self.start()
+ return self._current.view
+
+ @pyqtSlot(str, result=str)
+ def viewToComponent(self, view):
+ return self.navmap[view]['gui'] + '.qml'
+
+ @pyqtSlot('QJSValue', result='QVariant')
+ def submit(self, wizard_data):
+ wdata = wizard_data.toVariant()
+ view = self.resolve_next(self._current.view, wdata)
+ return { 'view': view.view, 'wizard_data': view.wizard_data }
+
+ @pyqtSlot(result='QVariant')
+ def prev(self):
+ viewstate = self.resolve_prev()
+ return viewstate.wizard_data
+
+ @pyqtSlot('QJSValue', result=bool)
+ def isLast(self, wizard_data):
+ wdata = wizard_data.toVariant()
+ return self.is_last_view(self._current.view, wdata)
+
+
+class QENewWalletWizard(NewWalletWizard, QEAbstractWizard):
+ createError = pyqtSignal([str], arguments=["error"])
+ createSuccess = pyqtSignal()
+
+ def __init__(self, daemon: 'QEDaemon', plugins: 'Plugins', parent=None):
+ NewWalletWizard.__init__(self, daemon.daemon, plugins)
+ QEAbstractWizard.__init__(self, parent)
+ self._qedaemon = daemon
+ self._path = None
+ self._password = None
+
+ # attach view names and accept handlers
+ self.navmap_merge({
+ 'wallet_name': {'gui': 'WCWalletName'},
+ 'wallet_type': {'gui': 'WCWalletType'},
+ 'keystore_type': {'gui': 'WCKeystoreType'},
+ 'create_seed': {'gui': 'WCCreateSeed'},
+ 'create_ext': {'gui': 'WCEnterExt'},
+ 'confirm_seed': {'gui': 'WCConfirmSeed'},
+ 'confirm_ext': {'gui': 'WCConfirmExt'},
+ 'have_seed': {'gui': 'WCHaveSeed'},
+ 'have_ext': {'gui': 'WCEnterExt'},
+ 'script_and_derivation': {'gui': 'WCScriptAndDerivation'},
+ 'have_master_key': {'gui': 'WCHaveMasterKey'},
+ 'multisig': {'gui': 'WCMultisig'},
+ 'multisig_cosigner_keystore': {'gui': 'WCCosignerKeystore'},
+ 'multisig_cosigner_key': {'gui': 'WCHaveMasterKey'},
+ 'multisig_cosigner_seed': {'gui': 'WCHaveSeed'},
+ 'multisig_cosigner_have_ext': {'gui': 'WCEnterExt'},
+ 'multisig_cosigner_script_and_derivation': {'gui': 'WCScriptAndDerivation'},
+ 'imported': {'gui': 'WCImport'},
+ 'wallet_password': {'gui': 'WCWalletPassword'}
+ })
+
+ pathChanged = pyqtSignal()
+ @pyqtProperty(str, notify=pathChanged)
+ def path(self):
+ return self._path
+
+ @path.setter
+ def path(self, path):
+ self._path = path
+ self.pathChanged.emit()
+
+ def is_single_password(self):
+ return self._qedaemon.singlePasswordEnabled
+
+ @pyqtSlot('QJSValue', result=bool)
+ def hasDuplicateMasterKeys(self, js_data):
+ self._logger.info('Checking for duplicate masterkeys')
+ data = js_data.toVariant()
+ return self.has_duplicate_masterkeys(data)
+
+ @pyqtSlot('QJSValue', result=bool)
+ def hasHeterogeneousMasterKeys(self, js_data):
+ self._logger.info('Checking for heterogeneous masterkeys')
+ data = js_data.toVariant()
+ return self.has_heterogeneous_masterkeys(data)
+
+ @pyqtSlot(str, str, result=bool)
+ def isMatchingSeed(self, seed, seed_again):
+ return mnemonic.is_matching_seed(seed=seed, seed_again=seed_again)
+
+ @pyqtSlot(str, str, str, result='QVariantMap')
+ def verifySeed(self, seed, seed_variant, wallet_type='standard'):
+ seed_valid, seed_type, validation_message, can_passphrase = self.validate_seed(seed, seed_variant, wallet_type)
+ return {
+ 'valid': seed_valid,
+ 'type': seed_type,
+ 'message': validation_message,
+ 'can_passphrase': can_passphrase
+ }
+
+ @pyqtSlot('QJSValue', bool, str)
+ def createStorage(self, js_data, single_password_enabled, single_password):
+ self._logger.info('Creating wallet from wizard data')
+ data = js_data.toVariant()
+
+ if single_password_enabled and single_password:
+ data['encrypt'] = True
+ data['password'] = single_password
+
+ path = self._qedaemon.wallet_path_from_wallet_name(data['wallet_name'])
+
+ try:
+ self.create_storage(path, data)
+
+ # minimally populate self after create
+ self._password = data['password']
+ self.path = path
+
+ self.createSuccess.emit()
+ except UserFacingException as e:
+ self._logger.debug(f"createStorage errored: {e!r}", exc_info=True)
+ self.createError.emit(str(e))
+ except Exception as e:
+ self._logger.exception(f"createStorage errored: {e!r}")
+ send_exception_to_crash_reporter(e)
+
+
+class QEServerConnectWizard(ServerConnectWizard, QEAbstractWizard):
+ def __init__(self, daemon: 'QEDaemon', parent=None):
+ ServerConnectWizard.__init__(self, daemon.daemon)
+ QEAbstractWizard.__init__(self, parent)
+
+ # attach view names
+ self.navmap_merge({
+ 'welcome': {'gui': 'WCWelcome'},
+ 'proxy_config': {'gui': 'WCProxyConfig'},
+ 'server_config': {'gui': 'WCServerConfig'},
+ })
+
+
+class QETermsOfUseWizard(TermsOfUseWizard, QEAbstractWizard):
+ def __init__(self, daemon: 'QEDaemon', parent=None):
+ TermsOfUseWizard.__init__(self, daemon.daemon.config)
+ QEAbstractWizard.__init__(self, parent)
+
+ # attach gui classes
+ self.navmap_merge({
+ 'terms_of_use': {'gui': 'WCTermsOfUseRequest'},
+ })
+
+ termsOfUseChanged = pyqtSignal()
+ @pyqtProperty(str, notify=termsOfUseChanged)
+ def termsOfUseText(self):
+ return messages.MSG_TERMS_OF_USE
diff --git a/electrum/gui/qml/util.py b/electrum/gui/qml/util.py
new file mode 100644
index 000000000000..46f3ffc7c747
--- /dev/null
+++ b/electrum/gui/qml/util.py
@@ -0,0 +1,42 @@
+import math
+import re
+
+from time import time
+from typing import Tuple
+
+from electrum.i18n import _
+
+
+# return delay in msec when expiry time string should be updated
+# returns 0 when expired or expires > 1 day away (no updates needed)
+def status_update_timer_interval(exp):
+ # very roughly according to util.age
+ exp_in = int(exp - time())
+ exp_in_min = int(exp_in/60)
+
+ interval = 0
+ if exp_in < 0:
+ interval = 0
+ elif exp_in_min < 2:
+ interval = 1000
+ elif exp_in_min < 90:
+ interval = 1000 * 60
+ elif exp_in_min < 1440:
+ interval = 1000 * 60 * 60
+
+ return interval
+
+
+# TODO: copied from qt password_dialog.py, move to common code
+def check_password_strength(password: str) -> Tuple[int, str]:
+ """Check the strength of the password entered by the user and return back the same
+ :param password: password entered by user in New Password
+ :return: password strength Weak or Medium or Strong"""
+ password = password
+ n = math.log(len(set(password)))
+ num = re.search("[0-9]", password) is not None and re.match("^[0-9]*$", password) is None
+ caps = password != password.upper() and password != password.lower()
+ extra = re.match("^[a-zA-Z0-9]*$", password) is None
+ score = len(password)*(n + caps + num + extra)/20
+ password_strength = {0: _('Weak'), 1: _('Medium'), 2: _('Strong'), 3: _('Very Strong')}
+ return min(3, int(score)), password_strength[min(3, int(score))]
diff --git a/electrum/gui/qt/__init__.py b/electrum/gui/qt/__init__.py
new file mode 100644
index 000000000000..32ac61d18cbb
--- /dev/null
+++ b/electrum/gui/qt/__init__.py
@@ -0,0 +1,641 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2012 thomasv@gitorious
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import os
+import signal
+import sys
+import threading
+from typing import Optional, TYPE_CHECKING, List, Sequence, Union
+
+try:
+ import PyQt6
+ import PyQt6.QtGui
+except Exception as e:
+ from electrum import GuiImportError
+ raise GuiImportError(
+ "Error: Could not import PyQt6. On Linux systems, "
+ "you may try 'sudo apt-get install python3-pyqt6'") from e
+
+from PyQt6.QtGui import QGuiApplication, QCursor
+from PyQt6.QtWidgets import QApplication, QSystemTrayIcon, QWidget, QMenu, QMessageBox, QDialog, QToolTip
+from PyQt6.QtCore import QObject, pyqtSignal, QTimer, Qt
+
+import PyQt6.QtCore as QtCore
+
+from electrum.logging import Logger, get_logger
+_logger = get_logger(__name__)
+
+try:
+ # Preload QtMultimedia at app start, if available.
+ # We use QtMultimedia on some platforms for camera-handling, and
+ # lazy-loading it later led to some crashes. Maybe due to bugs in PyQt. (see #7725)
+ from PyQt6.QtMultimedia import QMediaDevices; del QMediaDevices
+except (ImportError, RuntimeError) as e:
+ _logger.debug(f"failed to import optional dependency: PyQt6.QtMultimedia. exc={repr(e)}")
+ pass # failure is ok; it is an optional dependency.
+else:
+ _logger.debug(f"successfully preloaded optional dependency: PyQt6.QtMultimedia")
+
+if sys.platform == "linux" and os.environ.get("APPIMAGE"):
+ # For AppImage, we default to xcb qt backend, for better support of older system.
+ # qt6 normally defaults to QT_QPA_PLATFORM=wayland instead of QT_QPA_PLATFORM=xcb.
+ # However, the wayland QPA plugin requires libwayland-client0>=1.19, which is too new
+ # for debian 11 or ubuntu 20.04. So instead, we default to the X11 integration (and not wayland).
+ # see https://bugreports.qt.io/browse/QTBUG-114635
+ os.environ.setdefault("QT_QPA_PLATFORM", "xcb")
+
+from electrum.i18n import _, set_language
+from electrum.plugin import run_hook
+from electrum.util import (UserCancelled, profiler, send_exception_to_crash_reporter,
+ WalletFileException, get_new_wallet_name, InvalidPassword,
+ standardize_path, UserFacingException)
+from electrum.wallet import Wallet, Abstract_Wallet
+from electrum.wallet_db import WalletRequiresSplit, WalletRequiresUpgrade, WalletUnfinished
+from electrum.gui import BaseElectrumGui
+from electrum.simple_config import SimpleConfig
+from electrum.wizard import WizardViewState
+from electrum.keystore import load_keystore
+from electrum.bip32 import is_xprv
+from electrum import constants
+
+from electrum.gui.common_qt.i18n import ElectrumTranslator
+from electrum.gui.messages import TERMS_OF_USE_LATEST_VERSION
+
+from .util import (read_QIcon, ColorScheme, custom_message_box, MessageBoxMixin, WWLabel,
+ set_windows_os_screenshot_protection_drm_flag)
+from .main_window import ElectrumWindow
+from .network_dialog import NetworkDialog
+from .stylesheet_patcher import patch_qt_stylesheet
+from .lightning_dialog import LightningDialog
+from .exception_window import Exception_Hook
+from .wizard.server_connect import QEServerConnectWizard
+from .wizard.wallet import QENewWalletWizard
+
+if TYPE_CHECKING:
+ from electrum.daemon import Daemon
+ from electrum.plugin import Plugins
+
+
+class OpenFileEventFilter(QObject):
+ def __init__(self, windows: Sequence[ElectrumWindow]):
+ self.windows = windows
+ super(OpenFileEventFilter, self).__init__()
+
+ def eventFilter(self, obj, event):
+ if event.type() == QtCore.QEvent.Type.FileOpen:
+ if len(self.windows) >= 1:
+ self.windows[0].set_payment_identifier(event.url().toString())
+ return True
+ return False
+
+
+class ScreenshotProtectionEventFilter(QObject):
+ def __init__(self):
+ super().__init__()
+
+ def eventFilter(self, obj, event):
+ if (
+ event.type() == QtCore.QEvent.Type.Show
+ and isinstance(obj, QWidget)
+ and obj.isWindow()
+ ):
+ set_windows_os_screenshot_protection_drm_flag(obj)
+ return False
+
+
+class QElectrumApplication(QApplication):
+ new_window_signal = pyqtSignal(str, object)
+ quit_signal = pyqtSignal()
+ refresh_tabs_signal = pyqtSignal()
+ refresh_amount_edits_signal = pyqtSignal()
+ update_status_signal = pyqtSignal()
+ update_fiat_signal = pyqtSignal()
+ alias_received_signal = pyqtSignal()
+
+
+class ElectrumGui(BaseElectrumGui, Logger):
+
+ network_dialog: Optional['NetworkDialog']
+ lightning_dialog: Optional['LightningDialog']
+
+ @profiler
+ def __init__(self, *, config: 'SimpleConfig', daemon: 'Daemon', plugins: 'Plugins'):
+ BaseElectrumGui.__init__(self, config=config, daemon=daemon, plugins=plugins)
+ 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
+ #plugins.add_jobs([DebugMem([Abstract_Wallet, SPV, Synchronizer,
+ # ElectrumWindow], interval=5)])
+ if hasattr(QtCore.Qt, "AA_ShareOpenGLContexts"):
+ QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_ShareOpenGLContexts)
+ if hasattr(QGuiApplication, 'setDesktopFileName'):
+ QGuiApplication.setDesktopFileName('electrum')
+ QGuiApplication.setApplicationName("Electrum")
+ self.gui_thread = threading.current_thread()
+ self.windows = [] # type: List[ElectrumWindow]
+ self.open_file_efilter = OpenFileEventFilter(self.windows)
+ self.app = QElectrumApplication(sys.argv)
+ self.app.installEventFilter(self.open_file_efilter)
+ self.screenshot_protection_efilter = ScreenshotProtectionEventFilter()
+ if sys.platform in ['win32', 'windows'] and self.config.GUI_QT_SCREENSHOT_PROTECTION:
+ self.app.installEventFilter(self.screenshot_protection_efilter)
+ # explicitly set 'AA_DontShowIconsInMenus' False so menu icons are shown on MacOS
+ self.app.setAttribute(Qt.ApplicationAttribute.AA_DontShowIconsInMenus, on=False)
+ self.app.setWindowIcon(read_QIcon("electrum.png"))
+ self.translator = ElectrumTranslator()
+ self.app.installTranslator(self.translator)
+ self._cleaned_up = False
+ self.network_dialog = None
+ self.lightning_dialog = None
+ self._num_wizards_in_progress = 0
+ self._num_wizards_lock = threading.Lock()
+ self.dark_icon = self.config.GUI_QT_DARK_TRAY_ICON
+ self.tray = None # type: Optional[QSystemTrayIcon]
+ self._init_tray()
+ self.app.new_window_signal.connect(self.start_new_window)
+ self.app.quit_signal.connect(self.app.quit, Qt.ConnectionType.QueuedConnection)
+ # maybe set dark theme
+ self._default_qtstylesheet = self.app.styleSheet()
+ self.reload_app_stylesheet()
+
+ def _init_tray(self):
+ self.tray = QSystemTrayIcon(self.tray_icon(), None)
+ self.tray.setToolTip('Electrum')
+ self.tray.activated.connect(self.tray_activated)
+ self.build_tray_menu()
+ self.tray.show()
+
+ def reload_app_stylesheet(self):
+ """Set the Qt stylesheet and custom colors according to the user-selected
+ light/dark theme.
+ TODO this can ~almost be used to change the theme at runtime (without app restart),
+ except for util.ColorScheme... widgets already created with colors set using
+ ColorSchemeItem.as_stylesheet() and similar will not get recolored.
+ See e.g.
+ - in Coins tab, the color for "frozen" UTXOs, or
+ - in TxDialog, the receiving/change address colors
+ """
+ use_dark_theme = self.config.GUI_QT_COLOR_THEME == 'dark'
+ if use_dark_theme:
+ try:
+ import qdarkstyle
+ self.app.setStyleSheet(qdarkstyle.load_stylesheet_pyqt6())
+ except BaseException as e:
+ use_dark_theme = False
+ self.logger.warning(f'Error setting dark theme: {repr(e)}')
+ else:
+ self.app.setStyleSheet(self._default_qtstylesheet)
+ # Apply any necessary stylesheet patches
+ patch_qt_stylesheet(use_dark_theme=use_dark_theme)
+ # Even if we ourselves don't set the dark theme,
+ # the OS/window manager/etc might set *a dark theme*.
+ # Hence, try to choose colors accordingly:
+ ColorScheme.update_from_widget(QWidget(), force_dark=use_dark_theme)
+
+ def build_tray_menu(self):
+ if not self.tray:
+ return
+ # Avoid immediate GC of old menu when window closed via its action
+ if self.tray.contextMenu() is None:
+ m = QMenu()
+ self.tray.setContextMenu(m)
+ else:
+ m = self.tray.contextMenu()
+ m.clear()
+ network = self.daemon.network
+ m.addAction(_("Plugins"), self.show_plugins_dialog)
+ if network:
+ m.addAction(_("Network"), self.show_network_dialog)
+ if network and network.lngossip:
+ m.addAction(_("Lightning Network"), self.show_lightning_dialog)
+ for window in self.windows:
+ name = window.wallet.basename()
+ submenu = m.addMenu(name)
+ submenu.addAction(_("Show/Hide"), window.show_or_hide)
+ submenu.addAction(_("Close"), window.close)
+ m.addAction(_("Dark/Light"), self.toggle_tray_icon)
+ m.addSeparator()
+ m.addAction(_("Exit Electrum"), self.app.quit)
+
+ def tray_icon(self):
+ if self.dark_icon:
+ return read_QIcon('electrum_dark_icon.png')
+ else:
+ return read_QIcon('electrum_light_icon.png')
+
+ def toggle_tray_icon(self):
+ if not self.tray:
+ return
+ self.dark_icon = not self.dark_icon
+ self.config.GUI_QT_DARK_TRAY_ICON = self.dark_icon
+ self.tray.setIcon(self.tray_icon())
+
+ def tray_activated(self, reason):
+ if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
+ if all([w.is_hidden() for w in self.windows]):
+ for w in self.windows:
+ w.bring_to_top()
+ else:
+ for w in self.windows:
+ w.hide()
+
+ def _cleanup_before_exit(self):
+ if self._cleaned_up:
+ return
+ self._cleaned_up = True
+ self.app.new_window_signal.disconnect()
+ self.app.removeEventFilter(self.open_file_efilter)
+ self.open_file_efilter = None
+ # it is save to remove the filter, even if it has not been installed
+ self.app.removeEventFilter(self.screenshot_protection_efilter)
+ self.screenshot_protection_efilter = None
+ # If there are still some open windows, try to clean them up.
+ for window in list(self.windows):
+ window.close()
+ window.clean_up()
+ if self.network_dialog:
+ self.network_dialog.close()
+ self.network_dialog = None
+ if self.lightning_dialog:
+ self.lightning_dialog.close()
+ self.lightning_dialog = None
+ # clipboard persistence. see http://www.mail-archive.com/pyqt@riverbankcomputing.com/msg17328.html
+ event = QtCore.QEvent(QtCore.QEvent.Type.Clipboard)
+ self.app.sendEvent(self.app.clipboard(), event)
+ if self.tray:
+ self.tray.hide()
+ self.tray.deleteLater()
+ self.tray = None
+
+ def _maybe_quit_if_no_windows_open(self) -> None:
+ """Check if there are any open windows and decide whether we should quit."""
+ # keep daemon running after close
+ if self.config.get('daemon'):
+ return
+ # check if a wizard is in progress
+ with self._num_wizards_lock:
+ if self._num_wizards_in_progress > 0 or len(self.windows) > 0:
+ return
+ 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.has_channel_db():
+ return
+ if not self.lightning_dialog:
+ self.lightning_dialog = LightningDialog(self)
+ self.lightning_dialog.bring_to_top()
+
+ def show_plugins_dialog(self):
+ from .plugins_dialog import PluginsDialog
+ d = PluginsDialog(self.config, self.plugins, gui_object=self)
+ d.exec()
+
+ def show_network_dialog(self, proxy_tab=False):
+ if self.network_dialog:
+ self.network_dialog.show(proxy_tab=proxy_tab)
+ self.network_dialog.raise_()
+ return
+ self.network_dialog = NetworkDialog(network=self.daemon.network)
+ self.network_dialog.show(proxy_tab=proxy_tab)
+
+ def _create_window_for_wallet(self, wallet):
+ w = ElectrumWindow(self, wallet)
+ self.windows.append(w)
+ self.build_tray_menu()
+ w.warn_if_testnet()
+ w.warn_if_watching_only()
+ return w
+
+ def count_wizards_in_progress(func):
+ def wrapper(self: 'ElectrumGui', *args, **kwargs):
+ with self._num_wizards_lock:
+ self._num_wizards_in_progress += 1
+ try:
+ return func(self, *args, **kwargs)
+ finally:
+ with self._num_wizards_lock:
+ self._num_wizards_in_progress -= 1
+ self._maybe_quit_if_no_windows_open()
+ return wrapper
+
+ def get_window_for_wallet(self, wallet):
+ for window in self.windows:
+ if window.wallet.storage.get_path() == wallet.storage.get_path():
+ return window
+
+ @count_wizards_in_progress
+ def start_new_window(
+ self,
+ path,
+ uri: Optional[str],
+ *,
+ app_is_starting: bool = False,
+ force_wizard: bool = False,
+ ) -> Optional[ElectrumWindow]:
+ """Raises the window for the wallet if it is open.
+ Otherwise, opens the wallet and creates a new window for it.
+ Warning: the returned window might be for a completely different wallet
+ than the provided path, as we allow user interaction to change the path.
+ """
+ if not self.has_accepted_terms_of_use():
+ self.logger.warning(f"terms of use not accepted, rejecting to start new window")
+ return None
+
+ def __handle_wallet_loading_exc(exc: Exception, pos):
+ if isinstance(exc, UserFacingException) \
+ or isinstance(exc, WalletFileException) and not exc.should_report_crash:
+ self.logger.exception(f"{pos=}")
+ custom_message_box(icon=QMessageBox.Icon.Warning,
+ parent=None,
+ title=_('Error'),
+ text=_('Cannot load wallet') + f' ({pos}):\n' + str(exc))
+ else:
+ send_exception_to_crash_reporter(exc)
+
+ wallet = None
+ # Try to open with daemon first. If this succeeds, there won't be a wizard at all
+ # (the wallet main window will appear directly).
+ if not force_wizard:
+ try:
+ wallet = self.daemon.load_wallet(path, None)
+ except FileNotFoundError:
+ pass # open with wizard below
+ except InvalidPassword:
+ pass # open with wizard below
+ except WalletRequiresSplit:
+ pass # open with wizard below
+ except WalletRequiresUpgrade:
+ pass # open with wizard below
+ except WalletUnfinished:
+ pass # open with wizard below
+ except Exception as e:
+ __handle_wallet_loading_exc(e, 1)
+ # if app is starting, still let wizard appear
+ if not app_is_starting:
+ return
+ # Open a wizard window. This lets the user e.g. enter a password, or select
+ # a different wallet.
+ try:
+ if not wallet:
+ wallet = self._start_wizard_to_select_or_create_wallet(path)
+ if not wallet:
+ return
+ window = self.get_window_for_wallet(wallet)
+ # create or raise window
+ if not window:
+ window = self._create_window_for_wallet(wallet)
+ except UserCancelled:
+ return
+ except Exception as e:
+ __handle_wallet_loading_exc(e, 2)
+ if app_is_starting:
+ # If we raise in this context, there are no more fallbacks, we will shut down.
+ # Worst case scenario, we might have gotten here without user interaction,
+ # in which case, if we raise now without user interaction, the same sequence of
+ # events is likely to repeat when the user restarts the process.
+ # So we play it safe: clear path, clear uri, force a wizard to appear.
+ try:
+ wallet_dir = os.path.dirname(path)
+ filename = get_new_wallet_name(wallet_dir)
+ except OSError:
+ path = self.config.get_fallback_wallet_path()
+ else:
+ path = os.path.join(wallet_dir, filename)
+ return self.start_new_window(path, uri=None, force_wizard=True)
+ return
+ window.bring_to_top()
+ window.setWindowState(window.windowState() & ~Qt.WindowState.WindowMinimized | Qt.WindowState.WindowActive)
+ window.activateWindow()
+ if uri:
+ window.show_send_tab()
+ # Handle URI defensively - local attacker with access to RPC server and config file could get here:
+ # - tell user something happened
+ window.notify(_("Updated 'Pay To' field to handle external URI"))
+ # - clear all fields in Send tab:
+ # - perhaps user was just filling out the fields, trying to make another payment.
+ # e.g. if the given URI does not have an amount, we should clear the amount field
+ window.send_tab.do_clear()
+ # - update "Pay To" field (and maybe others)
+ window.send_tab.set_payment_identifier(uri)
+ return window
+
+ def _start_wizard_to_select_or_create_wallet(self, path) -> Optional[Abstract_Wallet]:
+ wizard = QENewWalletWizard(self.config, self.app, self.plugins, self.daemon, path)
+ result = wizard.exec()
+ # TODO: use dialog.open() instead to avoid new event loop spawn?
+ self.logger.info(f'wizard dialog exec result={result}')
+ if result == QDialog.DialogCode.Rejected:
+ self.logger.info('wizard dialog cancelled by user')
+ return
+
+ d = wizard.get_wizard_data()
+
+ if d['wallet_is_open']:
+ wallet_path = standardize_path(d['wallet_name'])
+ for window in self.windows:
+ if window.wallet.storage.get_path() == wallet_path:
+ return window.wallet
+ raise Exception('found by wizard but not here?!')
+
+ if not d['wallet_exists']:
+ self.logger.info('about to create wallet')
+ wizard.create_storage()
+ if d['wallet_type'] == '2fa' and 'x3' not in d:
+ return
+ wallet_file = wizard.path
+ else:
+ wallet_file = d['wallet_name']
+
+ password = d.get('password') or None # convert '' to None
+
+ try:
+ wallet = self.daemon.load_wallet(wallet_file, password, upgrade=True)
+ return wallet
+ except WalletRequiresSplit as e:
+ wizard.run_split(wallet_file, e._split_data)
+ return
+ except WalletUnfinished as e:
+ # wallet creation is not complete, 2fa online phase
+ db = e._wallet_db
+ action = db.get_action()
+ assert action[1] == 'accept_terms_of_use', 'only support for resuming trustedcoin split setup'
+ k1 = load_keystore(db, 'x1')
+ if password is not None:
+ xprv = k1.get_master_private_key(password)
+ else:
+ xprv = db.get('x1')['xprv']
+ if not is_xprv(xprv):
+ xprv = k1
+ _wiz_data_updates = {
+ 'wallet_name': wallet_file,
+ 'xprv1': xprv,
+ 'xpub1': db.get('x1')['xpub'],
+ 'xpub2': db.get('x2')['xpub'],
+ }
+ data = {**d, **_wiz_data_updates}
+ wizard = QENewWalletWizard(self.config, self.app, self.plugins, self.daemon, path,
+ start_viewstate=WizardViewState('trustedcoin_tos', data, {}))
+ result = wizard.exec()
+ if result == QDialog.DialogCode.Rejected:
+ self.logger.info('wizard dialog cancelled by user')
+ return
+ db.put('x3', wizard.get_wizard_data()['x3'])
+ db.write_and_force_consolidation() # TODO API for db is a bit weird: there should be a close method
+
+ wallet = self.daemon.load_wallet(wallet_file, password, upgrade=True)
+ return wallet
+
+ def close_window(self, window: ElectrumWindow):
+ if window in self.windows:
+ self.windows.remove(window)
+ self.build_tray_menu()
+ run_hook('on_close_window', window)
+ if window.should_stop_wallet_on_close:
+ self.daemon.stop_wallet(window.wallet.storage.get_path())
+
+ def reload_window(self, window):
+ # bump counter so that we do not close the app
+ self._num_wizards_in_progress += 1
+ wallet = window.wallet
+ window.should_stop_wallet_on_close = False
+ window.close()
+ self._create_window_for_wallet(wallet)
+ self._num_wizards_in_progress -= 1
+
+ def reload_windows(self):
+ for window in list(self.windows):
+ self.reload_window(window)
+
+ def has_accepted_terms_of_use(self) -> bool:
+ if self.config.TERMS_OF_USE_ACCEPTED >= TERMS_OF_USE_LATEST_VERSION\
+ or constants.net.NET_NAME == "regtest":
+ return True
+ return False
+
+ def ask_terms_of_use(self):
+ """Ask the user to accept the terms of use.
+ This is only shown if the user has not accepted them yet.
+ """
+ if self.has_accepted_terms_of_use():
+ return
+ from electrum.gui.qt.wizard.terms_of_use import QETermsOfUseWizard
+ dialog = QETermsOfUseWizard(self.config, self.app)
+ result = dialog.exec()
+ if result == QDialog.DialogCode.Rejected:
+ self.logger.info('terms of use not accepted by user')
+ raise UserCancelled()
+
+ def init_network(self):
+ """Start the network, including showing a first-start network dialog if config does not exist."""
+ if self.daemon.network:
+ # first-start network-setup
+ if not self.config.cv.NETWORK_AUTO_CONNECT.is_set():
+ dialog = QEServerConnectWizard(self.config, self.app, self.plugins, self.daemon)
+ result = dialog.exec()
+ if result == QDialog.DialogCode.Rejected:
+ self.logger.info('network wizard dialog cancelled by user')
+ raise UserCancelled()
+
+ # start network
+ self.daemon.start_network()
+
+ def main(self):
+ # setup Ctrl-C handling and tear-down code first, so that user can easily exit whenever
+ self.app.setQuitOnLastWindowClosed(False) # so _we_ can decide whether to quit
+ self.app.lastWindowClosed.connect(self._maybe_quit_if_no_windows_open)
+ self.app.aboutToQuit.connect(self._cleanup_before_exit)
+ signal.signal(signal.SIGINT, lambda *args: self.app.quit())
+ # hook for crash reporter
+ Exception_Hook.maybe_setup(config=self.config)
+ # start network, and maybe show first-start network-setup
+ try:
+ self.ask_terms_of_use()
+ self.init_network()
+ except UserCancelled:
+ return
+ except Exception as e:
+ self.logger.exception('')
+ return
+ # start wizard to select/create wallet
+ path = self.config.get_wallet_path()
+ try:
+ if not self.start_new_window(path, self.config.get('url'), app_is_starting=True):
+ return
+ except Exception as e:
+ self.logger.error("error loading wallet (or creating window for it)")
+ send_exception_to_crash_reporter(e)
+ # Let Qt event loop start properly so that crash reporter window can appear.
+ # We will shutdown when the user closes that window, via lastWindowClosed signal.
+ # main loop
+ self.logger.info("starting Qt main loop")
+ self.app.exec()
+ # on some platforms the exec_ call may not return, so use _cleanup_before_exit
+
+ def stop(self):
+ self.logger.info('closing GUI')
+ self.app.quit_signal.emit()
+
+ @classmethod
+ def version_info(cls):
+ ret = {
+ "qt.version": QtCore.QT_VERSION_STR,
+ "pyqt.version": QtCore.PYQT_VERSION_STR,
+ }
+ if hasattr(PyQt6, "__path__"):
+ ret["pyqt.path"] = ", ".join(PyQt6.__path__ or [])
+ return ret
+
+ def do_copy(self, text: str, *, title: str = None) -> None:
+ self.app.clipboard().setText(text)
+ message = _("Text copied to Clipboard") if title is None else _("{} copied to Clipboard").format(title)
+ # tooltip cannot be displayed immediately when called from a menu; wait 200ms
+ QTimer.singleShot(200, lambda: QToolTip.showText(QCursor.pos(), message, None))
+
+
+def standalone_exception_dialog(exception: Union[str, BaseException]) -> None:
+ app = QApplication.instance()
+ if not app:
+ app = QApplication([])
+
+ msg_box = QMessageBox()
+ msg_box.setWindowTitle(_("Error starting Electrum"))
+ msg_box.setIcon(QMessageBox.Icon.Critical)
+ msg_box.setText(_("An error occurred") + ":")
+ msg_box.setInformativeText(str(exception))
+
+ # Add detailed traceback if available
+ if hasattr(exception, "__traceback__"):
+ import traceback
+ detailed_text = ''.join(traceback.format_exception(
+ type(exception), exception, exception.__traceback__)
+ )
+ msg_box.setDetailedText(detailed_text)
+
+ msg_box.exec()
diff --git a/electrum/gui/qt/address_dialog.py b/electrum/gui/qt/address_dialog.py
new file mode 100644
index 000000000000..e06c65132af4
--- /dev/null
+++ b/electrum/gui/qt/address_dialog.py
@@ -0,0 +1,121 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2012 thomasv@gitorious
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from typing import TYPE_CHECKING
+
+from PyQt6.QtWidgets import QVBoxLayout, QLabel
+
+from electrum.i18n import _
+
+from .util import WindowModalDialog, ButtonsLineEdit, ShowQRLineEdit, Buttons, CloseButton
+from .history_list import HistoryList, HistoryModel
+from .qrtextedit import ShowQRTextEdit
+
+if TYPE_CHECKING:
+ from .main_window import ElectrumWindow
+
+
+class AddressHistoryModel(HistoryModel):
+ def __init__(self, window: 'ElectrumWindow', address):
+ super().__init__(window)
+ self.address = address
+
+ def get_domain(self):
+ return [self.address]
+
+ def should_include_lightning_payments(self) -> bool:
+ return False
+
+
+class AddressDialog(WindowModalDialog):
+
+ def __init__(self, window: 'ElectrumWindow', address: str, *, parent=None):
+ if parent is None:
+ parent = window
+ WindowModalDialog.__init__(self, parent, _("Address"))
+ self.address = address
+ self.window = window
+ self.config = window.config
+ self.wallet = window.wallet
+ self.app = window.app
+ self.saved = True
+
+ self.setMinimumWidth(700)
+ vbox = QVBoxLayout()
+ self.setLayout(vbox)
+
+ vbox.addWidget(QLabel(_("Address") + ":"))
+ self.addr_e = ShowQRLineEdit(self.address, self.config, title=_("Address"))
+ vbox.addWidget(self.addr_e)
+
+ try:
+ pubkeys = self.wallet.get_public_keys(address)
+ except BaseException as e:
+ pubkeys = None
+ if pubkeys:
+ vbox.addWidget(QLabel(_("Public keys") + ':'))
+ for pubkey in pubkeys:
+ pubkey_e = ShowQRLineEdit(pubkey, self.config, title=_("Public Key"))
+ vbox.addWidget(pubkey_e)
+
+ redeem_script = self.wallet.get_redeem_script(address)
+ if redeem_script:
+ vbox.addWidget(QLabel(_("Redeem Script") + ':'))
+ redeem_e = ShowQRTextEdit(text=redeem_script, config=self.config)
+ redeem_e.addCopyButton()
+ vbox.addWidget(redeem_e)
+
+ witness_script = self.wallet.get_witness_script(address)
+ if witness_script:
+ vbox.addWidget(QLabel(_("Witness Script") + ':'))
+ witness_e = ShowQRTextEdit(text=witness_script, config=self.config)
+ witness_e.addCopyButton()
+ 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()
+ der_path_e.setReadOnly(True)
+ vbox.addWidget(der_path_e)
+
+ addr_hist_model = AddressHistoryModel(self.window, self.address)
+ self.hw = HistoryList(self.window, addr_hist_model)
+ self.hw.num_tx_label = QLabel('')
+ addr_hist_model.set_view(self.hw)
+ vbox.addWidget(self.hw.num_tx_label)
+ vbox.addWidget(self.hw)
+
+ vbox.addLayout(Buttons(CloseButton(self)))
+ self.format_amount = self.window.format_amount
+ addr_hist_model.refresh('address dialog constructor')
+
+ def show_qr(self):
+ text = self.address
+ try:
+ self.window.show_qrcode(text, 'Address', parent=self)
+ except Exception as e:
+ self.show_message(repr(e))
diff --git a/electrum/gui/qt/address_list.py b/electrum/gui/qt/address_list.py
new file mode 100644
index 000000000000..ee077041f96b
--- /dev/null
+++ b/electrum/gui/qt/address_list.py
@@ -0,0 +1,371 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2015 Thomas Voegtlin
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import enum
+from enum import IntEnum
+from typing import TYPE_CHECKING, Optional
+
+from PyQt6.QtCore import Qt, QPersistentModelIndex, QModelIndex
+from PyQt6.QtGui import QStandardItemModel, QStandardItem, QFont
+from PyQt6.QtWidgets import QAbstractItemView, QComboBox, QMenu
+
+from electrum.i18n import _
+from electrum.util import block_explorer_URL, profiler
+from electrum.plugin import run_hook
+from electrum.bitcoin import is_address
+from electrum.wallet import InternalAddressCorruption
+from electrum.simple_config import SimpleConfig
+
+from .util import MONOSPACE_FONT, ColorScheme, webopen
+from .my_treeview import MyTreeView, MySortModel
+from ..messages import MSG_FREEZE_ADDRESS
+
+if TYPE_CHECKING:
+ from .main_window import ElectrumWindow
+ from electrum.wallet import AddressIndexGeneric
+
+
+class AddressUsageStateFilter(IntEnum):
+ ALL = 0
+ UNUSED = 1
+ FUNDED = 2
+ USED_AND_EMPTY = 3
+ FUNDED_OR_UNUSED = 4
+
+ def ui_text(self) -> str:
+ return {
+ self.ALL: _('All status'),
+ self.UNUSED: _('Unused'),
+ self.FUNDED: _('Funded'),
+ self.USED_AND_EMPTY: _('Used'),
+ self.FUNDED_OR_UNUSED: _('Funded or Unused'),
+ }[self]
+
+
+class AddressTypeFilter(IntEnum):
+ ALL = 0
+ RECEIVING = 1
+ CHANGE = 2
+
+ def ui_text(self) -> str:
+ return {
+ self.ALL: _('All types'),
+ self.RECEIVING: _('Receiving'),
+ self.CHANGE: _('Change'),
+ }[self]
+
+
+class AddressList(MyTreeView):
+
+ class Columns(MyTreeView.BaseColumnsEnum):
+ TYPE = enum.auto()
+ ADDRESS = enum.auto()
+ LABEL = enum.auto()
+ COIN_BALANCE = enum.auto()
+ FIAT_BALANCE = enum.auto()
+ NUM_TXS = enum.auto()
+
+ filter_columns = [Columns.TYPE, Columns.ADDRESS, Columns.LABEL, Columns.COIN_BALANCE]
+
+ ROLE_SORT_ORDER = Qt.ItemDataRole.UserRole + 1000
+ ROLE_ADDRESS_STR = Qt.ItemDataRole.UserRole + 1001
+ key_role = ROLE_ADDRESS_STR
+
+ def __init__(self, main_window: 'ElectrumWindow'):
+ super().__init__(
+ main_window=main_window,
+ stretch_column=self.Columns.LABEL,
+ editable_columns=[self.Columns.LABEL],
+ )
+ self.wallet = self.main_window.wallet
+ self._address_list_status = 0 # type: int
+ self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
+ self.setSortingEnabled(True)
+ self.show_change = AddressTypeFilter.ALL # type: AddressTypeFilter
+ self.show_used = AddressUsageStateFilter.ALL # type: AddressUsageStateFilter
+ self.change_button = QComboBox(self)
+ self.change_button.currentIndexChanged.connect(self.toggle_change)
+ for addr_type in AddressTypeFilter.__members__.values(): # type: AddressTypeFilter
+ self.change_button.addItem(addr_type.ui_text())
+ self.used_button = QComboBox(self)
+ 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.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.SortOrder.AscendingOrder)
+ if self.config:
+ self.configvar_show_toolbar = self.config.cv.GUI_QT_ADDRESSES_TAB_SHOW_TOOLBAR
+
+ def on_double_click(self, idx):
+ addr = self.get_role_data_for_current_item(col=0, role=self.ROLE_ADDRESS_STR)
+ self.main_window.show_address(addr)
+
+ def create_toolbar(self, config: 'SimpleConfig'):
+ toolbar, menu = self.create_toolbar_with_menu('')
+ self.num_addr_label = toolbar.itemAt(0).widget()
+ self._toolbar_checkbox = menu.addToggle(_("Show Filter"), lambda: self.toggle_toolbar())
+ menu.addConfig(config.cv.FX_SHOW_FIAT_BALANCE_FOR_ADDRESSES, callback=self.main_window.app.update_fiat_signal.emit)
+ hbox = self.create_toolbar_buttons()
+ toolbar.insertLayout(1, hbox)
+ return toolbar
+
+ def should_show_fiat(self):
+ return self.main_window.fx and self.main_window.fx.is_enabled() and self.config.FX_SHOW_FIAT_BALANCE_FOR_ADDRESSES
+
+ def get_toolbar_buttons(self):
+ return self.change_button, self.used_button
+
+ def on_hide_toolbar(self):
+ self.show_change = AddressTypeFilter.ALL # type: AddressTypeFilter
+ self.show_used = AddressUsageStateFilter.ALL # type: AddressUsageStateFilter
+ self.update()
+
+ def refresh_headers(self):
+ if self.should_show_fiat():
+ ccy = self.main_window.fx.get_currency()
+ else:
+ ccy = _('Fiat')
+ headers = {
+ self.Columns.TYPE: _('Type'),
+ self.Columns.ADDRESS: _('Address'),
+ self.Columns.LABEL: _('Label'),
+ self.Columns.COIN_BALANCE: _('Balance'),
+ self.Columns.FIAT_BALANCE: ccy + ' ' + _('Balance'),
+ self.Columns.NUM_TXS: _('Tx'),
+ }
+ self.update_headers(headers)
+
+ def toggle_change(self, state: int):
+ if state == self.show_change:
+ return
+ self.show_change = AddressTypeFilter(state)
+ self.update()
+
+ def toggle_used(self, state: int):
+ if state == self.show_used:
+ return
+ self.show_used = AddressUsageStateFilter(state)
+ self.update()
+
+ @profiler
+ def update(self):
+ if self.maybe_defer_update():
+ return
+ current_address = self.get_role_data_for_current_item(col=0, role=self.ROLE_ADDRESS_STR)
+ if self.show_change == AddressTypeFilter.RECEIVING:
+ addr_list = self.wallet.get_receiving_addresses()
+ elif self.show_change == AddressTypeFilter.CHANGE:
+ addr_list = self.wallet.get_change_addresses()
+ else:
+ addr_list = self.wallet.get_addresses()
+ self.proxy.setDynamicSortFilter(False) # temp. disable re-sorting after every change
+ self.std_model.clear()
+ self.refresh_headers()
+ set_address = None
+ num_shown = 0
+ new_address_list_status = 0
+ self.addresses_beyond_gap_limit = self.wallet.get_all_known_addresses_beyond_gap_limit()
+ for address in addr_list:
+ c, u, x = self.wallet.get_addr_balance(address)
+ balance = c + u + x
+ is_used_and_empty = self.wallet.adb.is_used(address) and balance == 0
+ if self.show_used == AddressUsageStateFilter.UNUSED and (balance or is_used_and_empty):
+ continue
+ if self.show_used == AddressUsageStateFilter.FUNDED and balance == 0:
+ continue
+ if self.show_used == AddressUsageStateFilter.USED_AND_EMPTY and not is_used_and_empty:
+ continue
+ if self.show_used == AddressUsageStateFilter.FUNDED_OR_UNUSED and is_used_and_empty:
+ continue
+ num_shown += 1
+ new_address_list_status = hash((new_address_list_status, address, c, u, x, is_used_and_empty))
+ labels = [""] * len(self.Columns)
+ labels[self.Columns.ADDRESS] = address
+ address_item = [QStandardItem(e) for e in labels]
+ # align text and set fonts
+ for i, item in enumerate(address_item):
+ item.setTextAlignment(Qt.AlignmentFlag.AlignVCenter)
+ if i not in (self.Columns.TYPE, self.Columns.LABEL):
+ item.setFont(QFont(MONOSPACE_FONT))
+ self.set_editability(address_item)
+ address_item[self.Columns.FIAT_BALANCE].setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
+ # setup column 0
+ if self.wallet.is_change(address):
+ address_item[self.Columns.TYPE].setText(_('change'))
+ address_item[self.Columns.TYPE].setBackground(ColorScheme.YELLOW.as_color(True))
+ else:
+ address_item[self.Columns.TYPE].setText(_('receiving'))
+ address_item[self.Columns.TYPE].setBackground(ColorScheme.GREEN.as_color(True))
+ address_item[self.Columns.TYPE].setData(address, self.ROLE_ADDRESS_STR)
+ address_path = self.wallet.get_address_index(address)
+ address_item[self.Columns.TYPE].setData(self.address_index_as_sortable_key(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)
+ # add item
+ count = self.std_model.rowCount()
+ self.std_model.insertRow(count, address_item)
+ self.refresh_row(address, count)
+ 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)
+ # show/hide columns
+ if self.should_show_fiat():
+ self.showColumn(self.Columns.FIAT_BALANCE)
+ else:
+ self.hideColumn(self.Columns.FIAT_BALANCE)
+ if self._address_list_status != new_address_list_status:
+ self._address_list_status = new_address_list_status
+ self.close_menu()
+ self.filter()
+ self.proxy.setDynamicSortFilter(True)
+ # update counter
+ self.num_addr_label.setText(_("{} addresses").format(num_shown))
+
+ @staticmethod
+ def address_index_as_sortable_key(address_index: Optional['AddressIndexGeneric']) -> str:
+ if isinstance(address_index, str): # pubkey hex
+ return address_index
+ elif address_index is None:
+ return ""
+ else:
+ return "".join(f"{i:08x}" for i in address_index)
+
+ def refresh_row(self, key, row):
+ assert row is not None
+ address = key
+ label = self.wallet.get_label_for_address(address)
+ num = self.wallet.adb.get_address_history_len(address)
+ c, u, x = self.wallet.get_addr_balance(address)
+ balance = c + u + x
+ balance_text = self.main_window.format_amount(balance, whitespaces=True)
+ balance_text_nots = self.main_window.format_amount(balance, whitespaces=False, add_thousands_sep=False)
+ # create item
+ fx = self.main_window.fx
+ if self.should_show_fiat():
+ rate = fx.exchange_rate()
+ fiat_balance_str = fx.value_str(balance, rate, add_thousands_sep=True)
+ fiat_balance_str_nots = fx.value_str(balance, rate, add_thousands_sep=False)
+ else:
+ fiat_balance_str = ''
+ fiat_balance_str_nots = ''
+ address_item = [self.std_model.item(row, col) for col in self.Columns]
+ address_item[self.Columns.LABEL].setText(label)
+ address_item[self.Columns.COIN_BALANCE].setText(balance_text)
+ address_item[self.Columns.COIN_BALANCE].setData(balance, self.ROLE_SORT_ORDER)
+ address_item[self.Columns.COIN_BALANCE].setData(balance_text_nots, self.ROLE_CLIPBOARD_DATA)
+ address_item[self.Columns.FIAT_BALANCE].setText(fiat_balance_str)
+ address_item[self.Columns.FIAT_BALANCE].setData(balance, self.ROLE_SORT_ORDER)
+ address_item[self.Columns.FIAT_BALANCE].setData(fiat_balance_str_nots, self.ROLE_CLIPBOARD_DATA)
+ address_item[self.Columns.NUM_TXS].setText("%d"%num)
+ c = ColorScheme.BLUE.as_color(True) if self.wallet.is_frozen_address(address) else self._default_bg_brush
+ address_item[self.Columns.ADDRESS].setBackground(c)
+ if address in self.addresses_beyond_gap_limit:
+ address_item[self.Columns.ADDRESS].setBackground(ColorScheme.RED.as_color(True))
+
+ def create_menu(self, position):
+ from electrum.wallet import Multisig_Wallet
+ is_multisig = isinstance(self.wallet, Multisig_Wallet)
+ can_delete = self.wallet.can_delete_address()
+ selected = self.selected_in_column(self.Columns.ADDRESS)
+ if not selected:
+ return
+ multi_select = len(selected) > 1
+ addrs = [self.item_from_index(item).text() for item in selected]
+ menu = QMenu()
+ menu.setToolTipsVisible(True)
+ if not multi_select:
+ idx = self.indexAt(position)
+ if not idx.isValid():
+ return
+ item = self.item_from_index(idx)
+ if not item:
+ return
+ addr = addrs[0]
+ menu.addAction(_('Details'), lambda: self.main_window.show_address(addr))
+ 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)
+ persistent = QPersistentModelIndex(addr_idx)
+ menu.addAction(_("Edit {}").format(addr_column_title), lambda p=persistent: self.edit(QModelIndex(p)))
+ #menu.addAction(_("Request payment"), lambda: self.main_window.receive_at(addr))
+ if self.wallet.can_export():
+ menu.addAction(_("Private key"), lambda: self.main_window.show_private_key(addr))
+ if not is_multisig and not self.wallet.is_watching_only():
+ menu.addAction(_("Sign/verify message"), lambda: self.main_window.sign_verify_message(addr))
+ menu.addAction(_("Encrypt/decrypt message"), lambda: self.main_window.encrypt_message(addr))
+ if can_delete:
+ menu.addAction(_("Remove from wallet"), lambda: self.main_window.remove_address(addr))
+ addr_URL = block_explorer_URL(self.config, 'addr', addr)
+ if addr_URL:
+ menu.addAction(_("View on block explorer"), lambda: webopen(addr_URL))
+
+ if not self.wallet.is_frozen_address(addr):
+ act = menu.addAction(_("Freeze"), lambda: self.main_window.set_frozen_state_of_addresses([addr], True))
+ else:
+ act = menu.addAction(_("Unfreeze"), lambda: self.main_window.set_frozen_state_of_addresses([addr], False))
+ act.setToolTip(MSG_FREEZE_ADDRESS)
+
+ else:
+ # multiple items selected
+ act = menu.addAction(_("Freeze"), lambda: self.main_window.set_frozen_state_of_addresses(addrs, True))
+ act.setToolTip(MSG_FREEZE_ADDRESS)
+ act = menu.addAction(_("Unfreeze"), lambda: self.main_window.set_frozen_state_of_addresses(addrs, False))
+ act.setToolTip(MSG_FREEZE_ADDRESS)
+
+ coins = self.wallet.get_spendable_coins(addrs)
+ if coins:
+ if self.main_window.utxo_list.are_in_coincontrol(coins):
+ menu.addAction(_("Remove from coin control"), lambda: self.main_window.utxo_list.remove_from_coincontrol(coins))
+ else:
+ menu.addAction(_("Add to coin control"), lambda: self.main_window.utxo_list.add_to_coincontrol(coins))
+
+ run_hook('receive_menu', menu, addrs, self.wallet)
+ self.open_menu(menu, position)
+
+ def place_text_on_clipboard(self, text: str, *, title: str = None) -> None:
+ if is_address(text):
+ try:
+ self.wallet.check_address_for_corruption(text)
+ except InternalAddressCorruption as e:
+ self.main_window.show_error(str(e))
+ raise
+ super().place_text_on_clipboard(text, title=title)
+
+ def get_edit_key_from_coordinate(self, row, col):
+ if col != self.Columns.LABEL:
+ return None
+ return self.get_role_data_from_coordinate(row, 0, role=self.ROLE_ADDRESS_STR)
+
+ def on_edited(self, idx, edit_key, *, text):
+ self.wallet.set_label(edit_key, text)
+ self.main_window.history_model.refresh('address label edited')
+ self.main_window.utxo_list.update()
+ self.main_window.update_completions()
diff --git a/electrum/gui/qt/amountedit.py b/electrum/gui/qt/amountedit.py
new file mode 100644
index 000000000000..7f12707b112e
--- /dev/null
+++ b/electrum/gui/qt/amountedit.py
@@ -0,0 +1,177 @@
+# -*- coding: utf-8 -*-
+
+from decimal import Decimal
+from typing import Union
+
+from PyQt6.QtCore import pyqtSignal, Qt, QSize
+from PyQt6.QtGui import QPainter
+from PyQt6.QtWidgets import (QLineEdit, QStyle, QStyleOptionFrame, QSizePolicy)
+
+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, DECIMAL_POINT, UI_UNIT_NAME_FEERATE_SAT_PER_VBYTE)
+from electrum.bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
+
+_NOT_GIVEN = object() # sentinel value
+
+
+class FreezableLineEdit(QLineEdit):
+ frozen = pyqtSignal()
+
+ def setFrozen(self, b):
+ self.setReadOnly(b)
+ self.setStyleSheet(ColorScheme.LIGHTBLUE.as_stylesheet(True) if b else '')
+ self.frozen.emit()
+
+ def isFrozen(self):
+ return self.isReadOnly()
+
+
+class SizedFreezableLineEdit(FreezableLineEdit):
+
+ def __init__(self, *, width: int, parent=None):
+ super().__init__(parent)
+ self._width = width
+ self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
+ self.setMaximumWidth(width)
+
+ def sizeHint(self) -> QSize:
+ sh = super().sizeHint()
+ return QSize(self._width, sh.height())
+
+
+class AmountEdit(SizedFreezableLineEdit):
+ shortcut = pyqtSignal()
+
+ def __init__(self, base_unit, is_int=False, parent=None, *, max_amount=None):
+ # This seems sufficient for hundred-BTC amounts with 8 decimals
+ width = 16 * char_width_in_lineedit()
+ super().__init__(width=width, parent=parent)
+ self.base_unit = base_unit
+ self.textChanged.connect(self.numbify)
+ self.is_int = is_int
+ self.is_shortcut = False
+ self.extra_precision = 0
+ self.max_amount = max_amount
+
+ def decimal_point(self):
+ return 8
+
+ def max_precision(self):
+ return self.decimal_point() + self.extra_precision
+
+ def numbify(self):
+ text = self.text().strip()
+ if text == '!':
+ self.shortcut.emit()
+ return
+ pos = self.cursorPosition()
+ chars = '0123456789'
+ if not self.is_int: chars += DECIMAL_POINT
+ s = ''.join([i for i in text if i in chars])
+ if not self.is_int:
+ if DECIMAL_POINT in s:
+ p = s.find(DECIMAL_POINT)
+ s = s.replace(DECIMAL_POINT, '')
+ s = s[:p] + DECIMAL_POINT + s[p:p+self.max_precision()]
+ if self.max_amount:
+ if (amt := self._get_amount_from_text(s)) and amt >= self.max_amount:
+ s = self._get_text_from_amount(self.max_amount)
+ self.setText(s)
+ # setText sets Modified to False. Instead we want to remember
+ # if updates were because of user modification.
+ self.setModified(self.hasFocus())
+ self.setCursorPosition(pos)
+
+ def paintEvent(self, event):
+ QLineEdit.paintEvent(self, event)
+ if self.base_unit:
+ panel = QStyleOptionFrame()
+ self.initStyleOption(panel)
+ textRect = self.style().subElementRect(QStyle.SubElement.SE_LineEditContents, panel, self)
+ textRect.adjust(2, 0, -10, 0)
+ painter = QPainter(self)
+ painter.setPen(ColorScheme.GRAY.as_color())
+ painter.drawText(textRect, int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter), self.base_unit())
+
+ def _get_amount_from_text(self, text: str) -> Union[None, Decimal, int]:
+ try:
+ text = text.replace(DECIMAL_POINT, '.')
+ return (int if self.is_int else Decimal)(text)
+ except Exception:
+ return None
+
+ def get_amount(self) -> Union[None, Decimal, int]:
+ amt = self._get_amount_from_text(str(self.text()))
+ if self.max_amount and amt and amt >= self.max_amount:
+ return self.max_amount
+ return amt
+
+ def _get_text_from_amount(self, amount) -> str:
+ return "%d" % amount
+
+ def setAmount(self, amount):
+ text = self._get_text_from_amount(amount)
+ self.setText(text)
+
+
+class BTCAmountEdit(AmountEdit):
+
+ def __init__(self, decimal_point, is_int=False, parent=None, *, max_amount=_NOT_GIVEN):
+ if max_amount is _NOT_GIVEN:
+ max_amount = TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN
+ AmountEdit.__init__(self, self._base_unit, is_int, parent, max_amount=max_amount)
+ self.decimal_point = decimal_point
+
+ def _base_unit(self):
+ return decimal_point_to_base_unit_name(self.decimal_point())
+
+ def _get_amount_from_text(self, text):
+ # returns amt in satoshis
+ try:
+ text = text.replace(DECIMAL_POINT, '.')
+ x = Decimal(text)
+ except Exception:
+ return None
+ # scale it to max allowed precision, make it an int
+ power = pow(10, self.max_precision())
+ max_prec_amount = int(power * x)
+ # if the max precision is simply what unit conversion allows, just return
+ if self.max_precision() == self.decimal_point():
+ return max_prec_amount
+ # otherwise, scale it back to the expected unit
+ 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 _get_text_from_amount(self, amount_sat):
+ text = format_satoshis_plain(amount_sat, decimal_point=self.decimal_point())
+ text = text.replace('.', DECIMAL_POINT)
+ return text
+
+ def setAmount(self, amount_sat):
+ if amount_sat is None:
+ self.setText(" ") # Space forces repaint in case units changed
+ else:
+ text = self._get_text_from_amount(amount_sat)
+ self.setText(text)
+ self.setFrozen(self.isFrozen()) # re-apply styling, as it is nuked by setText (?)
+ self.repaint() # macOS hack for #6269
+
+
+class FeerateEdit(BTCAmountEdit):
+
+ def __init__(self, decimal_point, is_int=False, parent=None, *, max_amount=_NOT_GIVEN):
+ super().__init__(decimal_point, is_int, parent, max_amount=max_amount)
+ self.extra_precision = FEERATE_PRECISION
+
+ def _base_unit(self):
+ return UI_UNIT_NAME_FEERATE_SAT_PER_VBYTE
+
+ def _get_amount_from_text(self, text):
+ sat_per_byte_amount = super()._get_amount_from_text(text)
+ return quantize_feerate(sat_per_byte_amount)
+
+ def _get_text_from_amount(self, amount):
+ amount = quantize_feerate(amount)
+ return super()._get_text_from_amount(amount)
diff --git a/electrum/gui/qt/balance_dialog.py b/electrum/gui/qt/balance_dialog.py
new file mode 100644
index 000000000000..e2b18fcc4507
--- /dev/null
+++ b/electrum/gui/qt/balance_dialog.py
@@ -0,0 +1,275 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2013 ecdsa@github
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from typing import TYPE_CHECKING
+
+from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel, QWidget, QGridLayout, QToolButton, QPushButton
+from PyQt6.QtCore import QRect, Qt
+from PyQt6.QtGui import QPen, QPainter, QPixmap
+
+from electrum.i18n import _
+from electrum.gui.messages import MSG_LN_UTXO_RESERVE
+
+from .util import Buttons, CloseButton, WindowModalDialog, ColorScheme, font_height, AmountLabel, icon_path
+
+if TYPE_CHECKING:
+ from .main_window import ElectrumWindow
+ from electrum.wallet import Abstract_Wallet
+
+
+# Todo:
+# show lightning funds that are not usable
+# pie chart mouse interactive, to prepare a swap
+
+COLOR_CONFIRMED = Qt.GlobalColor.green
+COLOR_UNCONFIRMED = Qt.GlobalColor.red
+COLOR_UNMATURED = Qt.GlobalColor.magenta
+COLOR_FROZEN = ColorScheme.BLUE.as_color(True)
+COLOR_LIGHTNING = Qt.GlobalColor.yellow
+COLOR_FROZEN_LIGHTNING = Qt.GlobalColor.cyan
+
+
+class PieChartObject:
+
+ def paintEvent(self, event):
+ pen = QPen(Qt.GlobalColor.gray, 1, Qt.PenStyle.SolidLine)
+ qp = QPainter()
+ qp.begin(self)
+ qp.setPen(pen)
+ qp.setRenderHint(QPainter.RenderHint.Antialiasing)
+ qp.setBrush(Qt.GlobalColor.gray)
+ total = sum([x[2] for x in self._list])
+ if total == 0:
+ return
+ alpha = 0
+ s = 0
+ for name, color, amount in self._list:
+ qp.setBrush(color)
+ if amount == 0:
+ continue
+ elif amount == total:
+ qp.drawEllipse(self.R)
+ else:
+ delta = int(16 * 360 * amount/total)
+ qp.drawPie(self.R, alpha, delta)
+ alpha += delta
+ qp.end()
+
+
+class PieChartWidget(QWidget, PieChartObject):
+
+ def __init__(self, size, l):
+ QWidget.__init__(self)
+ self.size = size
+ self.R = QRect(0, 0, self.size, self.size)
+ self.setGeometry(self.R)
+ self.setMinimumWidth(self.size)
+ self.setMaximumWidth(self.size)
+ self.setMinimumHeight(self.size)
+ self.setMaximumHeight(self.size)
+ self._list = l # list[ (name, color, amount)]
+ self.update()
+
+ def update_list(self, l):
+ self._list = l
+ self.update()
+
+
+class BalanceToolButton(QToolButton, PieChartObject):
+
+ def __init__(self):
+ QToolButton.__init__(self)
+ self._list = []
+ self._update_size()
+ self._warning = False
+
+ @property
+ def has_warning(self) -> bool:
+ return bool(self._warning)
+
+ def update_list(self, l, warning: bool):
+ self._warning = warning
+ self._list = l
+ self.update()
+
+ def setText(self, text):
+ # this is a hack
+ QToolButton.setText(self, ' ' + text)
+
+ def paintEvent(self, event):
+ QToolButton.paintEvent(self, event)
+ if not self._warning:
+ PieChartObject.paintEvent(self, event)
+ else:
+ pixmap = QPixmap(icon_path("warning.png"))
+ qp = QPainter()
+ qp.begin(self)
+ qp.drawPixmap(self.R, pixmap)
+ qp.end()
+
+ def resizeEvent(self, e):
+ super().resizeEvent(e)
+ self._update_size()
+
+ def _update_size(self):
+ size = round(font_height(self) * 1.1)
+ self.R = QRect(6, 3, size, size)
+
+
+class LegendWidget(QWidget):
+ size = 20
+
+ def __init__(self, color):
+ QWidget.__init__(self)
+ self.color = color
+ self.R = QRect(0, 0, self.size, int(self.size*0.75))
+ self.setGeometry(self.R)
+ self.setMinimumWidth(self.size)
+ self.setMaximumWidth(self.size)
+ self.setMinimumHeight(self.size)
+ self.setMaximumHeight(self.size)
+
+ def paintEvent(self, event):
+ pen = QPen(Qt.GlobalColor.gray, 1, Qt.PenStyle.SolidLine)
+ qp = QPainter()
+ qp.begin(self)
+ qp.setPen(pen)
+ qp.setRenderHint(QPainter.RenderHint.Antialiasing)
+ qp.setBrush(self.color)
+ qp.drawRect(self.R)
+ qp.end()
+
+
+class BalanceDialog(WindowModalDialog):
+
+ def __init__(self, parent: 'ElectrumWindow', *, wallet: 'Abstract_Wallet'):
+
+ WindowModalDialog.__init__(self, parent, _("Wallet Balance"))
+ self.wallet = wallet
+ self.window = parent
+ self.config = parent.config
+ self.fx = parent.fx
+
+ p_bal = self.wallet.get_balances_for_piechart()
+ confirmed = p_bal.confirmed
+ unconfirmed = p_bal.unconfirmed
+ unmatured = p_bal.unmatured
+ frozen = p_bal.frozen
+ lightning = p_bal.lightning
+ f_lightning = p_bal.lightning_frozen
+
+ frozen_str = self.config.format_amount_and_units(frozen)
+ confirmed_str = self.config.format_amount_and_units(confirmed)
+ unconfirmed_str = self.config.format_amount_and_units(unconfirmed)
+ unmatured_str = self.config.format_amount_and_units(unmatured)
+ lightning_str = self.config.format_amount_and_units(lightning)
+ f_lightning_str = self.config.format_amount_and_units(f_lightning)
+
+ frozen_fiat_str = self.fx.format_amount_and_units(frozen) if self.fx else ''
+ confirmed_fiat_str = self.fx.format_amount_and_units(confirmed) if self.fx else ''
+ unconfirmed_fiat_str = self.fx.format_amount_and_units(unconfirmed) if self.fx else ''
+ unmatured_fiat_str = self.fx.format_amount_and_units(unmatured) if self.fx else ''
+ lightning_fiat_str = self.fx.format_amount_and_units(lightning) if self.fx else ''
+ f_lightning_fiat_str = self.fx.format_amount_and_units(f_lightning) if self.fx else ''
+
+ piechart = PieChartWidget(
+ max(120, 9 * font_height()),
+ [
+ (_('Frozen'), COLOR_FROZEN, frozen),
+ (_('Unmatured'), COLOR_UNMATURED, unmatured),
+ (_('Unconfirmed'), COLOR_UNCONFIRMED, unconfirmed),
+ (_('On-chain'), COLOR_CONFIRMED, confirmed),
+ (_('Lightning'), COLOR_LIGHTNING, lightning),
+ (_('Lightning frozen'), COLOR_FROZEN_LIGHTNING, f_lightning),
+ ]
+ )
+
+ vbox = QVBoxLayout()
+ if self.wallet.is_low_reserve():
+ reserve_str = self.config.format_amount_and_units(self.config.LN_UTXO_RESERVE)
+ hbox = QHBoxLayout()
+ msg = _('Warning') + ': ' + MSG_LN_UTXO_RESERVE.format(reserve_str)
+ label = QLabel(msg)
+ label.setWordWrap(True)
+ logo = QLabel('')
+ logo.setPixmap(
+ QPixmap(icon_path("warning.png")).scaledToWidth(
+ 25, mode=Qt.TransformationMode.SmoothTransformation)
+ )
+ logo.setMaximumWidth(28)
+ hbox.addWidget(logo)
+ hbox.addWidget(label)
+ vbox.addLayout(hbox)
+
+ vbox.addWidget(piechart)
+ grid = QGridLayout()
+ #grid.addWidget(QLabel(_("Onchain") + ':'), 0, 1)
+ #grid.addWidget(QLabel(onchain_str), 0, 2, alignment=Qt.AlignmentFlag.AlignRight)
+ #grid.addWidget(QLabel(onchain_fiat_str), 0, 3, alignment=Qt.AlignmentFlag.AlignRight)
+
+ if frozen:
+ grid.addWidget(LegendWidget(COLOR_FROZEN), 0, 0)
+ grid.addWidget(QLabel(_("Frozen") + ':'), 0, 1)
+ grid.addWidget(AmountLabel(frozen_str), 0, 2, alignment=Qt.AlignmentFlag.AlignRight)
+ grid.addWidget(AmountLabel(frozen_fiat_str), 0, 3, alignment=Qt.AlignmentFlag.AlignRight)
+ if unconfirmed:
+ grid.addWidget(LegendWidget(COLOR_UNCONFIRMED), 2, 0)
+ grid.addWidget(QLabel(_("Unconfirmed") + ':'), 2, 1)
+ grid.addWidget(AmountLabel(unconfirmed_str), 2, 2, alignment=Qt.AlignmentFlag.AlignRight)
+ grid.addWidget(AmountLabel(unconfirmed_fiat_str), 2, 3, alignment=Qt.AlignmentFlag.AlignRight)
+ if unmatured:
+ grid.addWidget(LegendWidget(COLOR_UNMATURED), 3, 0)
+ grid.addWidget(QLabel(_("Unmatured") + ':'), 3, 1)
+ grid.addWidget(AmountLabel(unmatured_str), 3, 2, alignment=Qt.AlignmentFlag.AlignRight)
+ grid.addWidget(AmountLabel(unmatured_fiat_str), 3, 3, alignment=Qt.AlignmentFlag.AlignRight)
+ if confirmed:
+ grid.addWidget(LegendWidget(COLOR_CONFIRMED), 1, 0)
+ grid.addWidget(QLabel(_("On-chain") + ':'), 1, 1)
+ grid.addWidget(AmountLabel(confirmed_str), 1, 2, alignment=Qt.AlignmentFlag.AlignRight)
+ grid.addWidget(AmountLabel(confirmed_fiat_str), 1, 3, alignment=Qt.AlignmentFlag.AlignRight)
+ if lightning:
+ grid.addWidget(LegendWidget(COLOR_LIGHTNING), 4, 0)
+ grid.addWidget(QLabel(_("Lightning") + ':'), 4, 1)
+ grid.addWidget(AmountLabel(lightning_str), 4, 2, alignment=Qt.AlignmentFlag.AlignRight)
+ grid.addWidget(AmountLabel(lightning_fiat_str), 4, 3, alignment=Qt.AlignmentFlag.AlignRight)
+ if f_lightning:
+ grid.addWidget(LegendWidget(COLOR_FROZEN_LIGHTNING), 5, 0)
+ grid.addWidget(QLabel(_("Lightning (frozen)") + ':'), 5, 1)
+ grid.addWidget(AmountLabel(f_lightning_str), 5, 2, alignment=Qt.AlignmentFlag.AlignRight)
+ grid.addWidget(AmountLabel(f_lightning_fiat_str), 5, 3, alignment=Qt.AlignmentFlag.AlignRight)
+
+ vbox.addLayout(grid)
+ vbox.addStretch(1)
+ buttons = [CloseButton(self)]
+ if self.window.wallet.has_lightning():
+ swap_button = QPushButton(_('Swap'))
+ swap_button.clicked.connect(lambda: self.window.run_swap_dialog())
+ buttons.insert(0, swap_button)
+
+ vbox.addLayout(Buttons(*buttons))
+ self.setLayout(vbox)
+
+ def run(self):
+ self.exec()
diff --git a/electrum/gui/qt/bip39_recovery_dialog.py b/electrum/gui/qt/bip39_recovery_dialog.py
new file mode 100644
index 000000000000..002dee4b39be
--- /dev/null
+++ b/electrum/gui/qt/bip39_recovery_dialog.py
@@ -0,0 +1,95 @@
+# 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
+
+import asyncio
+import concurrent.futures
+
+from PyQt6.QtCore import Qt
+from PyQt6.QtWidgets import QWidget, QVBoxLayout, 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 electrum.util import get_asyncio_loop, UserFacingException
+
+from electrum.gui.common_qt.util import TaskThread
+
+from .util import WindowModalDialog, Buttons, CancelButton, OkButton
+
+_logger = get_logger(__name__)
+
+
+class Bip39RecoveryDialog(WindowModalDialog):
+
+ ROLE_ACCOUNT = Qt.ItemDataRole.UserRole
+
+ 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.thread = TaskThread(self)
+ self.thread.finished.connect(self.deleteLater) # see #3956
+ network = Network.get_instance()
+ coro = account_discovery(network, self.get_account_xpub)
+ fut = asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop())
+ self.thread.add(
+ fut.result,
+ on_success=self.on_recovery_success,
+ on_error=self.on_recovery_error,
+ cancel=fut.cancel,
+ )
+
+ self.ok_button = OkButton(self)
+ self.ok_button.clicked.connect(self.on_ok_button_click)
+ self.ok_button.setEnabled(False)
+ cancel_button = CancelButton(self)
+ cancel_button.clicked.connect(fut.cancel)
+ vbox.addLayout(Buttons(cancel_button, self.ok_button))
+ self.finished.connect(self.on_finished)
+ self.show()
+
+ def on_finished(self):
+ self.thread.stop()
+
+ def on_ok_button_click(self):
+ item = self.list.currentItem()
+ account = item.data(self.ROLE_ACCOUNT)
+ self.on_account_select(account)
+
+ 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(self.ROLE_ACCOUNT, 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):
+ e = exc_info[1]
+ if isinstance(e, concurrent.futures.CancelledError):
+ return
+ self.clear_content()
+ msg = _('Error: Account discovery failed.')
+ if isinstance(e, UserFacingException):
+ msg += f"\n{e}"
+ else:
+ _logger.error(f"recovery error", exc_info=exc_info)
+ self.content.addWidget(QLabel(msg))
+
+ def clear_content(self):
+ for i in reversed(range(self.content.count())):
+ self.content.itemAt(i).widget().setParent(None)
diff --git a/electrum/gui/qt/channel_details.py b/electrum/gui/qt/channel_details.py
new file mode 100644
index 000000000000..42da866d0085
--- /dev/null
+++ b/electrum/gui/qt/channel_details.py
@@ -0,0 +1,280 @@
+from typing import TYPE_CHECKING, Sequence
+
+import PyQt6.QtGui as QtGui
+import PyQt6.QtWidgets as QtWidgets
+import PyQt6.QtCore as QtCore
+from PyQt6.QtWidgets import QLabel, QHBoxLayout
+
+from electrum.util import ShortID
+from electrum.i18n import _
+from electrum.lnutil import LOCAL, REMOTE, UpdateAddHtlc, Direction
+from electrum.lnchannel import Channel, AbstractChannel, HTLCWithStatus
+
+from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
+from .util import Buttons, CloseButton, ShowQRLineEdit, MessageBoxMixin, WWLabel, VLine
+
+if TYPE_CHECKING:
+ from .main_window import ElectrumWindow
+
+
+class HTLCItem(QtGui.QStandardItem):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.setEditable(False)
+
+
+class SelectableLabel(QtWidgets.QLabel):
+ def __init__(self, text=''):
+ super().__init__(text)
+ self.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse)
+
+
+class LinkedLabel(QtWidgets.QLabel):
+ def __init__(self, text, on_clicked):
+ super().__init__(text)
+ self.linkActivated.connect(on_clicked)
+
+
+class ChannelDetailsDialog(QtWidgets.QDialog, MessageBoxMixin, QtEventListener):
+
+ def __init__(self, window: 'ElectrumWindow', chan: AbstractChannel):
+ super().__init__(window)
+ # initialize instance fields
+ self.window = window
+ self.wallet = window.wallet
+ self.chan = chan
+ self.format_msat = lambda msat: window.format_amount_and_units(msat / 1000)
+ self.format_sat = lambda sat: window.format_amount_and_units(sat)
+ # register callbacks for updating
+ self.register_callbacks()
+ title = _('Lightning Channel') if not self.chan.is_backup() else _('Channel Backup')
+ self.setWindowTitle(title)
+ self.setMinimumSize(800, 400)
+ # activity labels. not used for backups.
+ self.local_balance_label = SelectableLabel()
+ self.remote_balance_label = SelectableLabel()
+ self.can_send_label = SelectableLabel()
+ self.can_receive_label = SelectableLabel()
+ # add widgets
+ vbox = QtWidgets.QVBoxLayout(self)
+ if self.chan.is_backup():
+ vbox.addWidget(QLabel('\n'.join([
+ _("This is a channel backup."),
+ _("It shows a channel that was opened with another instance of this wallet"),
+ _("A backup does not contain information about your local balance in the channel."),
+ _("You can use it to request a force close.")
+ ])))
+
+ form = self.get_common_form(chan)
+ vbox.addLayout(form)
+ if not self.chan.is_closed() and not self.chan.is_backup():
+ hbox_stats = self.get_hbox_stats(chan)
+ form.addRow(QLabel(_('Channel stats')+ ':'), hbox_stats)
+
+ if not self.chan.is_backup():
+ # add htlc tree view to vbox (wouldn't scale correctly in QFormLayout)
+ vbox.addWidget(QLabel(_('Payments (HTLCs):')))
+ w = self.create_htlc_list(chan)
+ vbox.addWidget(w)
+
+ vbox.addLayout(Buttons(CloseButton(self)))
+ # initialize sent/received fields
+ self.update()
+
+ 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_msat(i.amount_msat))])
+ it.appendRow([HTLCItem(_('CLTV expiry')), HTLCItem(str(i.cltv_abs))])
+ it.appendRow([HTLCItem(_('Payment hash')),HTLCItem(i.payment_hash.hex())])
+ return it
+
+ def make_model(self, htlcs: Sequence[HTLCWithStatus]) -> QtGui.QStandardItemModel:
+ 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'),
+ }
+ self.folders = {}
+ self.keyname_rows = {}
+
+ for keyname, i in folder_types.items():
+ myFont=QtGui.QFont()
+ myFont.setBold(True)
+ folder = HTLCItem(i)
+ folder.setFont(myFont)
+ parentItem.appendRow(folder)
+ self.folders[keyname] = folder
+ mapping = {}
+ num = 0
+ for htlc_with_status in htlcs:
+ if htlc_with_status.status != keyname:
+ continue
+ htlc = htlc_with_status.htlc
+ it = self.make_htlc_item(htlc, htlc_with_status.direction)
+ self.folders[keyname].appendRow(it)
+ mapping[htlc.payment_hash] = num
+ num += 1
+ self.keyname_rows[keyname] = mapping
+ return model
+
+ def move(self, fro: str, to: str, payment_hash: bytes):
+ assert fro != to
+ row_idx = self.keyname_rows[fro].pop(payment_hash)
+ row = self.folders[fro].takeRow(row_idx)
+ self.folders[to].appendRow(row)
+ dest_mapping = self.keyname_rows[to]
+ dest_mapping[payment_hash] = len(dest_mapping)
+
+ @qt_event_listener
+ def on_event_channel(self, wallet, chan):
+ if chan == self.chan:
+ self.update()
+
+ @qt_event_listener
+ def on_event_htlc_added(self, 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))
+
+ @qt_event_listener
+ def on_event_htlc_fulfilled(self, payment_hash, chan, htlc_id):
+ if chan.channel_id != self.chan.channel_id:
+ return
+ self.move('inflight', 'settled', payment_hash)
+ self.update()
+
+ @qt_event_listener
+ def on_event_htlc_failed(self, payment_hash, chan, htlc_id):
+ if chan.channel_id != self.chan.channel_id:
+ return
+ self.move('inflight', 'failed', payment_hash)
+ self.update()
+
+ def update(self):
+ if self.chan.is_closed() or self.chan.is_backup():
+ return
+ assert isinstance(self.chan, Channel), type(self.chan)
+ 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)))
+ self.local_balance_label.setText(self.format_msat(self.chan.balance(LOCAL)))
+ self.remote_balance_label.setText(self.format_msat(self.chan.balance(REMOTE)))
+ self.current_feerate.setText(self.window.format_fee_rate(4 * self.chan.get_latest_feerate(LOCAL)))
+
+ @QtCore.pyqtSlot(str)
+ def show_tx(self, link_text: str):
+ tx = self.wallet.adb.get_transaction(link_text)
+ if not tx:
+ self.show_error(_("Transaction not found."))
+ return
+ self.window.show_transaction(tx)
+
+ def get_common_form(self, chan: AbstractChannel):
+ form = QtWidgets.QFormLayout(None)
+ remote_id_e = ShowQRLineEdit(chan.node_id.hex(), self.window.config, title=_("Remote Node ID"))
+ form.addRow(QLabel(_('Remote Node') + ':'), remote_id_e)
+ channel_id_e = ShowQRLineEdit(chan.channel_id.hex(), self.window.config, title=_("Channel ID"))
+ form.addRow(QLabel(_('Channel ID') + ':'), channel_id_e)
+ form.addRow(QLabel(_('Short Channel ID') + ':'), SelectableLabel(str(chan.short_channel_id)))
+ if local_scid_alias := chan.get_local_scid_alias():
+ form.addRow(QLabel('Local SCID Alias:'), SelectableLabel(str(ShortID(local_scid_alias))))
+ if remote_scid_alias := chan.get_remote_scid_alias():
+ form.addRow(QLabel('Remote SCID Alias:'), SelectableLabel(str(ShortID(remote_scid_alias))))
+ form.addRow(QLabel(_('State') + ':'), SelectableLabel(chan.get_state_for_GUI()))
+ if remote_peer_sent_error := chan.get_remote_peer_sent_error():
+ err_label = WWLabel(remote_peer_sent_error) # note: text is already truncated to reasonable len
+ err_label.setTextFormat(QtCore.Qt.TextFormat.PlainText)
+ form.addRow(WWLabel(_('Remote peer sent error [DO NOT TRUST]') + ':'), err_label)
+ self.capacity = self.format_sat(chan.get_capacity())
+ form.addRow(QLabel(_('Capacity') + ':'), SelectableLabel(self.capacity))
+ if not chan.is_backup():
+ form.addRow(QLabel(_('Channel type:')), SelectableLabel(chan.storage['channel_type'].name_minimal))
+ initiator = 'Local' if chan.constraints.is_initiator else 'Remote'
+ form.addRow(QLabel(_('Initiator:')), SelectableLabel(initiator))
+ else:
+ form.addRow(QLabel("Backup Type"), QLabel("imported" if self.chan.is_imported else "on-chain"))
+ funding_txid = chan.funding_outpoint.txid
+ funding_label_text = f'{funding_txid}:{chan.funding_outpoint.output_index}'
+ form.addRow(QLabel(_('Funding Outpoint') + ':'), LinkedLabel(funding_label_text, self.show_tx))
+ if chan.is_closed():
+ item = chan.get_closing_height()
+ if item:
+ closing_txid, closing_height, timestamp = item
+ closing_label_text = f'{closing_txid}'
+ form.addRow(QLabel(_('Closing Transaction') + ':'), LinkedLabel(closing_label_text, self.show_tx))
+ return form
+
+ def get_hbox_stats(self, chan: Channel):
+ hbox_stats = QHBoxLayout()
+ form_layout_left = QtWidgets.QFormLayout(None)
+ form_layout_right = QtWidgets.QFormLayout(None)
+ form_layout_left.addRow(_('Local balance') + ':', self.local_balance_label)
+ form_layout_right.addRow(_('Remote balance') + ':', self.remote_balance_label)
+ form_layout_left.addRow(_('Can send') + ':', self.can_send_label)
+ form_layout_right.addRow(_('Can receive') + ':', self.can_receive_label)
+ local_reserve_label = SelectableLabel("{}".format(
+ self.format_sat(chan.config[LOCAL].reserve_sat),
+ ))
+ remote_reserve_label = SelectableLabel("{}".format(
+ self.format_sat(chan.config[REMOTE].reserve_sat),
+ ))
+ form_layout_left.addRow(_('Local reserve') + ':', local_reserve_label)
+ form_layout_right.addRow(_('Remote reserve' + ':'), remote_reserve_label)
+ #self.htlc_minimum_msat = SelectableLabel(str(chan.config[REMOTE].htlc_minimum_msat))
+ #form_layout_left.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_left.addRow(_('Maximum number of concurrent HTLCs accepted by peer:'), self.max_htlcs)
+ #self.max_htlc_value = SelectableLabel(self.format_sat(chan.config[REMOTE].max_htlc_value_in_flight_msat / 1000))
+ #form_layout_left.addRow(_('Maximum value of in-flight HTLCs accepted by peer:'), self.max_htlc_value)
+ local_dust_limit_label = SelectableLabel("{}".format(
+ self.format_sat(chan.config[LOCAL].dust_limit_sat),
+ ))
+ remote_dust_limit_label = SelectableLabel("{}".format(
+ self.format_sat(chan.config[REMOTE].dust_limit_sat),
+ ))
+ form_layout_left.addRow(_('Local dust limit') + ':', local_dust_limit_label)
+ form_layout_right.addRow(_('Remote dust limit') + ':', remote_dust_limit_label)
+ self.received_label = SelectableLabel()
+ self.sent_label = SelectableLabel()
+ form_layout_left.addRow(_('Total sent') + ':', self.sent_label)
+ form_layout_right.addRow(_('Total received') + ':', self.received_label)
+ # to-self-delay
+ csv_local_header = SelectableLabel(_("Remote force-close CSV delay") + ":")
+ csv_local_header.setToolTip(_("Force-close CSV delay imposed on them"))
+ csv_remote_header = SelectableLabel(_("Local force-close CSV delay") + ":")
+ csv_remote_header.setToolTip(_("Force-close CSV delay imposed on us"))
+ csv_local_label = SelectableLabel(_("{} blocks").format(chan.config[LOCAL].to_self_delay))
+ csv_remote_label = SelectableLabel(_("{} blocks").format(chan.config[REMOTE].to_self_delay))
+ form_layout_left.addRow(csv_local_header, csv_local_label)
+ form_layout_right.addRow(csv_remote_header, csv_remote_label)
+ # onchain feerate
+ self.current_feerate = SelectableLabel()
+ form_layout_left.addRow(_('Current feerate') + ':', self.current_feerate)
+ # channel stats left column
+ hbox_stats.addLayout(form_layout_left, 50)
+ # vertical line separator
+ hbox_stats.addWidget(VLine())
+ # channel stats right column
+ hbox_stats.addLayout(form_layout_right, 50)
+ return hbox_stats
+
+ def create_htlc_list(self, chan):
+ w = QtWidgets.QTreeView(self)
+ htlc_dict = chan.get_payments()
+ htlc_list = []
+ for rhash, plist in htlc_dict.items():
+ for htlc_with_status in plist:
+ htlc_list.append(htlc_with_status)
+ w.setModel(self.make_model(htlc_list))
+ w.header().setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents)
+ return w
+
+ def closeEvent(self, event):
+ self.unregister_callbacks()
+ event.accept()
diff --git a/electrum/gui/qt/channels_list.py b/electrum/gui/qt/channels_list.py
new file mode 100644
index 000000000000..02e298284ec5
--- /dev/null
+++ b/electrum/gui/qt/channels_list.py
@@ -0,0 +1,492 @@
+# -*- coding: utf-8 -*-
+import traceback
+import enum
+from typing import Sequence, Optional, Dict, TYPE_CHECKING
+from abc import abstractmethod, ABC
+
+from PyQt6 import QtCore, QtGui
+from PyQt6.QtCore import Qt, QRect, QSize
+from PyQt6.QtWidgets import QMenu, QLabel, QVBoxLayout, QGridLayout, QAbstractItemView, QCheckBox, QToolTip
+from PyQt6.QtGui import QFont, QStandardItem, QBrush, QPainter, QIcon, QHelpEvent
+
+from electrum.i18n import _
+from electrum.lnchannel import AbstractChannel, ChannelBackup, Channel, ChanCloseOption
+from electrum.wallet import Abstract_Wallet
+from electrum.lnutil import LOCAL, REMOTE
+from electrum.lnworker import LNWallet
+from electrum.gui import messages
+
+from .util import WindowModalDialog, Buttons, OkButton, EnterButton, WaitingDialog, MONOSPACE_FONT, ColorScheme
+from .util import read_QIcon, font_height
+from .my_treeview import MyTreeView
+
+if TYPE_CHECKING:
+ from .main_window import ElectrumWindow
+
+
+ROLE_CHANNEL_ID = Qt.ItemDataRole.UserRole
+
+
+class ChannelsList(MyTreeView):
+ update_rows = QtCore.pyqtSignal(Abstract_Wallet)
+ update_single_row = QtCore.pyqtSignal(Abstract_Wallet, AbstractChannel)
+ gossip_db_loaded = QtCore.pyqtSignal()
+
+ class Columns(MyTreeView.BaseColumnsEnum):
+ FEATURES = enum.auto()
+ SHORT_CHANID = enum.auto()
+ NODE_ALIAS = enum.auto()
+ CAPACITY = enum.auto()
+ LOCAL_BALANCE = enum.auto()
+ REMOTE_BALANCE = enum.auto()
+ CHANNEL_STATUS = enum.auto()
+ LONG_CHANID = enum.auto()
+
+ headers = {
+ Columns.SHORT_CHANID: _('Short Channel ID'),
+ Columns.LONG_CHANID: _('Channel ID'),
+ Columns.NODE_ALIAS: _('Node alias'),
+ Columns.FEATURES: "",
+ Columns.CAPACITY: _('Capacity'),
+ Columns.LOCAL_BALANCE: _('Can send'),
+ Columns.REMOTE_BALANCE: _('Can receive'),
+ Columns.CHANNEL_STATUS: _('Status'),
+ }
+
+ filter_columns = [
+ Columns.SHORT_CHANID,
+ Columns.LONG_CHANID,
+ Columns.NODE_ALIAS,
+ Columns.CHANNEL_STATUS,
+ ]
+
+ _default_item_bg_brush = None # type: Optional[QBrush]
+
+ def __init__(self, main_window: 'ElectrumWindow'):
+ super().__init__(
+ main_window=main_window,
+ stretch_column=self.Columns.NODE_ALIAS,
+ )
+ self.setModel(QtGui.QStandardItemModel(self))
+ self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
+ 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.main_window.network
+ self.wallet = self.main_window.wallet
+ self.setSortingEnabled(True)
+
+ @property
+ # property because lnworker might be initialized at runtime
+ def lnworker(self):
+ return self.wallet.lnworker
+
+ def format_fields(self, chan: AbstractChannel) -> Dict['ChannelsList.Columns', str]:
+ labels = {}
+ for subject in (REMOTE, LOCAL):
+ if isinstance(chan, Channel):
+ can_send = chan.available_to_spend(subject) / 1000
+ label = self.main_window.format_amount(can_send, whitespaces=True)
+ other = subject.inverted()
+ bal_other = chan.balance(other)//1000
+ bal_minus_htlcs_other = chan.balance_minus_outgoing_htlcs(other)//1000
+ if bal_other != bal_minus_htlcs_other:
+ label += ' (+' + self.main_window.format_amount(bal_other - bal_minus_htlcs_other, whitespaces=False) + ')'
+ else:
+ assert isinstance(chan, ChannelBackup)
+ label = ''
+ labels[subject] = label
+ status = chan.get_state_for_GUI()
+ closed = chan.is_closed()
+ node_alias = self.lnworker.lnpeermgr.get_node_alias(chan.node_id) or chan.node_id.hex()
+ capacity_str = self.main_window.format_amount(chan.get_capacity(), whitespaces=True)
+ return {
+ self.Columns.SHORT_CHANID: chan.short_id_for_GUI(),
+ self.Columns.LONG_CHANID: chan.channel_id.hex(),
+ self.Columns.NODE_ALIAS: node_alias,
+ self.Columns.FEATURES: '',
+ self.Columns.CAPACITY: capacity_str,
+ self.Columns.LOCAL_BALANCE: '' if closed else labels[LOCAL],
+ self.Columns.REMOTE_BALANCE: '' if closed else labels[REMOTE],
+ self.Columns.CHANNEL_STATUS: status,
+ }
+
+ def on_channel_closed(self, txid):
+ self.main_window.show_message('Channel closed' + '\n' + txid)
+
+ def on_failure(self, exc_info):
+ type_, e, tb = exc_info
+ traceback.print_tb(tb)
+ self.main_window.show_error('Failed to close channel:\n{}'.format(repr(e)))
+
+ def close_channel(self, channel_id):
+ self.is_force_close = False
+ msg = _('Cooperative close?')
+ msg += '\n\n' + messages.MSG_COOPERATIVE_CLOSE
+ if not self.main_window.question(msg):
+ return
+ coro = self.lnworker.close_channel(channel_id)
+ on_success = self.on_channel_closed
+
+ def task():
+ return self.network.run_from_another_thread(coro)
+
+ WaitingDialog(self, _('Please wait...'), task, on_success, self.on_failure)
+
+ def force_close(self, channel_id):
+ self.save_backup = True
+ backup_cb = QCheckBox('Create a backup now', checked=True)
+
+ def on_checked(_x):
+ self.save_backup = backup_cb.isChecked()
+
+ backup_cb.stateChanged.connect(on_checked)
+ chan = self.lnworker.channels[channel_id]
+ to_self_delay = chan.config[REMOTE].to_self_delay
+ msg = '' + _('Force-close channel?') + ' '\
+ + '
' + _('If you force-close this channel, the funds you have in it will not be available for {} blocks.').format(to_self_delay) + ' '\
+ + _('After that delay, funds will be swept to an address derived from your wallet seed.') + '
'\
+ + '' + _('Please create a backup of your wallet file!') + ' '\
+ + '
' + _('Funds in this channel will not be recoverable from seed until they are swept back into your wallet, and might be lost if you lose your wallet file.') + ' '\
+ + _('To prevent that, you should save a backup of your wallet on another device.') + '
'
+ if not self.main_window.question(msg, title=_('Force-close channel'), rich_text=True, checkbox=backup_cb):
+ return
+ if self.save_backup:
+ if not self.main_window.backup_wallet():
+ return
+
+ 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_channel_closed, 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.lnworker.remove_channel_backup(channel_id)
+
+ def export_channel_backup(self, channel_id):
+ msg = messages.MSG_LN_EXPLAIN_SCB_BACKUPS
+ 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):
+ msg = _('Request force-close from remote peer?')
+ msg += '\n\n' + messages.MSG_REQUEST_FORCE_CLOSE
+ if not self.main_window.question(msg):
+ return
+
+ def task():
+ coro = self.lnworker.request_force_close(channel_id)
+ return self.network.run_from_another_thread(coro)
+
+ def on_done(b):
+ self.main_window.show_message(_('Request scheduled'))
+
+ WaitingDialog(self, _('Please wait...'), task, on_done, self.on_failure)
+
+ def set_frozen(self, chan, *, for_sending, value):
+ if not self.lnworker.uses_trampoline() or self.lnworker.is_trampoline_peer(chan.node_id):
+ if for_sending:
+ chan.set_frozen_for_sending(value)
+ else:
+ chan.set_frozen_for_receiving(value)
+ else:
+ msg = messages.MSG_NON_TRAMPOLINE_CHANNEL_FROZEN_WITHOUT_GOSSIP
+ self.main_window.show_warning(msg, title=_('Channel is frozen for sending'))
+
+ def get_rebalance_pair(self):
+ selected = self.selected_in_column(self.Columns.NODE_ALIAS)
+ if len(selected) == 2:
+ idx1 = selected[0]
+ idx2 = selected[1]
+ channel_id1 = idx1.sibling(idx1.row(), self.Columns.NODE_ALIAS).data(ROLE_CHANNEL_ID)
+ channel_id2 = idx2.sibling(idx2.row(), self.Columns.NODE_ALIAS).data(ROLE_CHANNEL_ID)
+ chan1 = self.lnworker.get_channel_by_id(channel_id1)
+ chan2 = self.lnworker.get_channel_by_id(channel_id2)
+ if chan1 and chan2 and (not self.lnworker.uses_trampoline() or chan1.node_id != chan2.node_id):
+ return chan1, chan2
+ return None, None
+
+ def on_rebalance(self):
+ chan1, chan2 = self.get_rebalance_pair()
+ if chan1 is None:
+ self.main_window.show_error("Select two active channels to rebalance.")
+ return
+ self.main_window.rebalance_dialog(chan1, chan2)
+
+ def on_double_click(self, idx):
+ channel_id = idx.sibling(idx.row(), self.Columns.NODE_ALIAS).data(ROLE_CHANNEL_ID)
+ chan = self.lnworker.get_channel_by_id(channel_id) or self.lnworker.channel_backups[channel_id]
+ self.main_window.show_channel_details(chan)
+
+ def create_menu(self, position):
+ menu = QMenu()
+ menu.setSeparatorsCollapsible(True) # consecutive separators are merged together
+ selected = self.selected_in_column(self.Columns.NODE_ALIAS)
+ if not selected:
+ menu.exec(self.viewport().mapToGlobal(position))
+ return
+ if len(selected) == 2:
+ chan1, chan2 = self.get_rebalance_pair()
+ if chan1 and chan2:
+ menu.addAction(_("Rebalance channels"), lambda: self.main_window.rebalance_dialog(chan1, chan2))
+ menu.exec(self.viewport().mapToGlobal(position))
+ return
+ elif len(selected) > 2:
+ 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_ALIAS).data(ROLE_CHANNEL_ID)
+ chan = self.lnworker.get_channel_by_id(channel_id) or self.lnworker.channel_backups[channel_id]
+ menu.addAction(_("Details"), lambda: self.main_window.show_channel_details(chan))
+ menu.addSeparator()
+ 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_backup() and not chan.is_closed():
+ fm = menu.addMenu(_("Freeze"))
+ if not chan.is_frozen_for_sending():
+ fm.addAction(_("Freeze for sending"), lambda: self.set_frozen(chan, for_sending=True, value=True))
+ else:
+ fm.addAction(_("Unfreeze for sending"), lambda: self.set_frozen(chan, for_sending=True, value=False))
+ if not chan.is_frozen_for_receiving():
+ fm.addAction(_("Freeze for receiving"), lambda: self.set_frozen(chan, for_sending=False, value=True))
+ else:
+ fm.addAction(_("Unfreeze for receiving"), lambda: self.set_frozen(chan, for_sending=False, value=False))
+ if close_opts := chan.get_close_options():
+ cm = menu.addMenu(_("Close"))
+ if ChanCloseOption.COOP_CLOSE in close_opts:
+ cm.addAction(_("Cooperative close"), lambda: self.close_channel(channel_id))
+ if ChanCloseOption.LOCAL_FCLOSE in close_opts:
+ cm.addAction(_("Force-close"), lambda: self.force_close(channel_id))
+ if ChanCloseOption.REQUEST_REMOTE_FCLOSE in close_opts:
+ cm.addAction(_("Request force-close"), lambda: self.request_force_close(channel_id))
+ if not chan.is_backup():
+ menu.addAction(_("Export backup"), lambda: self.export_channel_backup(channel_id))
+ if chan.can_be_deleted():
+ menu.addSeparator()
+ if chan.is_backup():
+ menu.addAction(_("Delete"), lambda: self.remove_channel_backup(channel_id))
+ else:
+ menu.addAction(_("Delete"), lambda: self.remove_channel(channel_id))
+ self.open_menu(menu, position)
+
+ @QtCore.pyqtSlot(Abstract_Wallet, AbstractChannel)
+ def do_update_single_row(self, wallet: Abstract_Wallet, chan: AbstractChannel):
+ if wallet != self.wallet:
+ return
+ for row in range(self.model().rowCount()):
+ item = self.model().item(row, self.Columns.NODE_ALIAS)
+ if item.data(ROLE_CHANNEL_ID) != chan.channel_id:
+ continue
+ for column, v in self.format_fields(chan).items():
+ self.model().item(row, column).setData(v, QtCore.Qt.ItemDataRole.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.wallet)
+
+ @QtCore.pyqtSlot(Abstract_Wallet)
+ def do_update_rows(self, wallet):
+ if wallet != self.wallet:
+ return
+ self.model().clear()
+ self.update_headers(self.headers)
+ self.set_visibility_of_columns()
+ if not wallet.lnworker:
+ return
+ self.update_can_send(wallet.lnworker)
+ channels = wallet.lnworker.get_channel_objects()
+ for chan in channels.values():
+ field_map = self.format_fields(chan)
+ items = [QtGui.QStandardItem(field_map[col]) for col in sorted(field_map)]
+ self.set_editability(items)
+ 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))
+ items[self.Columns.FEATURES].setData(ChannelFeatureIcons.from_channel(chan), self.ROLE_CUSTOM_PAINT)
+ items[self.Columns.CAPACITY].setFont(QFont(MONOSPACE_FONT))
+ self._update_chan_frozen_bg(chan=chan, items=items)
+ self.model().insertRow(0, items)
+
+ # FIXME sorting by SHORT_CHANID should treat values as tuple, not as string ( 50x1x1 > 8x1x1 )
+ self.sortByColumn(self.Columns.SHORT_CHANID, Qt.SortOrder.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.main_window.format_amount(lnworker.num_sats_can_send())\
+ + ' ' + self.main_window.base_unit() + '; '\
+ + _('can receive') + ' ' + self.main_window.format_amount(lnworker.num_sats_can_receive())\
+ + ' ' + self.main_window.base_unit()
+ self.can_send_label.setText(msg)
+
+ def create_toolbar(self, config):
+ toolbar, menu = self.create_toolbar_with_menu('')
+ self.can_send_label = toolbar.itemAt(0).widget()
+ menu.addAction(_('Rebalance channels'), lambda: self.on_rebalance())
+ menu.addAction(read_QIcon('update.png'), _('Submarine swap'), lambda: self.main_window.run_swap_dialog())
+ menu.addSeparator()
+ menu.addAction(_("Import channel backup"), lambda: self.main_window.do_process_from_text_channel_backup())
+ # only enable menu if has LN. Or we could selectively enable menu items?
+ # and maybe add item "main_window.init_lightning_dialog()" when applicable
+ menu.setEnabled(self.wallet.has_lightning())
+ self.new_channel_button = EnterButton(_('New Channel'), self.main_window.new_channel_dialog)
+ if not self.wallet.can_have_lightning():
+ self.new_channel_button.setEnabled(False)
+ self.new_channel_button.setToolTip(_("Lightning is not available for this wallet."))
+ else:
+ self.new_channel_button.setToolTip(_("Open a channel to send payments over the Lightning network."))
+ toolbar.insertWidget(2, self.new_channel_button)
+ return toolbar
+
+ def statistics_dialog(self):
+ channel_db = self.network.channel_db
+ capacity = self.main_window.format_amount(channel_db.capacity()) + ' '+ self.main_window.base_unit()
+ d = WindowModalDialog(self.main_window, _('Lightning Network Statistics'))
+ d.setMinimumWidth(400)
+ vbox = QVBoxLayout(d)
+ h = QGridLayout()
+ h.addWidget(QLabel(_('Nodes') + ':'), 0, 0)
+ h.addWidget(QLabel('{}'.format(channel_db.num_nodes)), 0, 1)
+ h.addWidget(QLabel(_('Channels') + ':'), 1, 0)
+ h.addWidget(QLabel('{}'.format(channel_db.num_channels)), 1, 1)
+ h.addWidget(QLabel(_('Capacity') + ':'), 2, 0)
+ h.addWidget(QLabel(capacity), 2, 1)
+ vbox.addLayout(h)
+ vbox.addLayout(Buttons(OkButton(d)))
+ d.exec()
+
+ def set_visibility_of_columns(self):
+ def set_visible(col: int, b: bool):
+ self.showColumn(col) if b else self.hideColumn(col)
+ set_visible(self.Columns.LONG_CHANID, False)
+
+
+class ChannelFeature(ABC):
+ def __init__(self):
+ self.rect = QRect()
+
+ @abstractmethod
+ def tooltip(self) -> str:
+ pass
+
+ @abstractmethod
+ def icon(self) -> QIcon:
+ pass
+
+
+class ChanFeatChannel(ChannelFeature):
+ def tooltip(self) -> str:
+ return _("This is a channel")
+ def icon(self) -> QIcon:
+ return read_QIcon("lightning")
+
+
+class ChanFeatBackup(ChannelFeature):
+ def tooltip(self) -> str:
+ return _("This is a static channel backup")
+ def icon(self) -> QIcon:
+ return read_QIcon("lightning_disconnected")
+
+
+class ChanFeatTrampoline(ChannelFeature):
+ def tooltip(self) -> str:
+ return _("The channel peer can route Trampoline payments.")
+ def icon(self) -> QIcon:
+ return read_QIcon("kangaroo")
+
+
+class ChanFeatNoOnchainBackup(ChannelFeature):
+ def tooltip(self) -> str:
+ return _("This channel cannot be recovered from your seed. You must back it up manually.")
+ def icon(self) -> QIcon:
+ return read_QIcon("cloud_no")
+
+
+
+class ChannelFeatureIcons:
+
+ def __init__(self, features: Sequence['ChannelFeature']):
+ size = max(16, font_height())
+ self.icon_size = QSize(size, size)
+ self.features = features
+
+ @classmethod
+ def from_channel(cls, chan: AbstractChannel) -> 'ChannelFeatureIcons':
+ feats = []
+ if chan.is_backup():
+ feats.append(ChanFeatBackup())
+ if chan.is_imported:
+ feats.append(ChanFeatNoOnchainBackup())
+ else:
+ feats.append(ChanFeatChannel())
+ if chan.lnworker.is_trampoline_peer(chan.node_id):
+ feats.append(ChanFeatTrampoline())
+ if not chan.has_onchain_backup():
+ feats.append(ChanFeatNoOnchainBackup())
+ return ChannelFeatureIcons(feats)
+
+ def paint(self, painter: QPainter, rect: QRect) -> None:
+ painter.save()
+ cur_x = rect.x()
+ for feat in self.features:
+ icon_rect = QRect(cur_x, rect.y(), self.icon_size.width(), self.icon_size.height())
+ feat.rect = icon_rect
+ if rect.contains(icon_rect): # stay inside parent
+ painter.drawPixmap(icon_rect, feat.icon().pixmap(self.icon_size))
+ cur_x += self.icon_size.width() + 1
+ painter.restore()
+
+ def sizeHint(self, default_size: QSize) -> QSize:
+ if not self.features:
+ return default_size
+ width = len(self.features) * (self.icon_size.width() + 1)
+ return QSize(width, default_size.height())
+
+ def show_tooltip(self, evt: QHelpEvent) -> bool:
+ assert isinstance(evt, QHelpEvent)
+ for feat in self.features:
+ if feat.rect.contains(evt.pos()):
+ QToolTip.showText(evt.globalPos(), feat.tooltip())
+ break
+ else:
+ QToolTip.hideText()
+ evt.ignore()
+ return True
diff --git a/electrum/gui/qt/completion_text_edit.py b/electrum/gui/qt/completion_text_edit.py
new file mode 100644
index 000000000000..74ab058cfff5
--- /dev/null
+++ b/electrum/gui/qt/completion_text_edit.py
@@ -0,0 +1,126 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2018 The Electrum developers
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from PyQt6.QtGui import QTextCursor
+from PyQt6.QtCore import Qt
+from PyQt6.QtWidgets import QCompleter, QPlainTextEdit, QApplication
+
+from .util import ButtonsTextEdit
+
+
+class CompletionTextEdit(ButtonsTextEdit):
+
+ def __init__(self):
+ ButtonsTextEdit.__init__(self)
+ self.completer = None
+ self.moveCursor(QTextCursor.MoveOperation.End)
+ self.disable_suggestions()
+
+ def set_completer(self, completer):
+ self.completer = completer
+ self.initialize_completer()
+
+ def initialize_completer(self):
+ self.completer.setWidget(self)
+ self.completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
+ self.completer.activated.connect(self.insert_completion)
+ self.enable_suggestions()
+
+ def insert_completion(self, completion):
+ if self.completer.widget() != self:
+ return
+ text_cursor = self.textCursor()
+ extra = len(completion) - len(self.completer.completionPrefix())
+ text_cursor.movePosition(QTextCursor.MoveOperation.Left)
+ text_cursor.movePosition(QTextCursor.MoveOperation.EndOfWord)
+ if extra == 0:
+ text_cursor.insertText(" ")
+ else:
+ text_cursor.insertText(completion[-extra:] + " ")
+ self.setTextCursor(text_cursor)
+
+ def text_under_cursor(self):
+ tc = self.textCursor()
+ tc.select(QTextCursor.SelectionType.WordUnderCursor)
+ return tc.selectedText()
+
+ def enable_suggestions(self):
+ self.suggestions_enabled = True
+
+ def disable_suggestions(self):
+ self.suggestions_enabled = False
+
+ def keyPressEvent(self, e):
+ if self.isReadOnly():
+ return
+
+ if self.is_special_key(e):
+ e.ignore()
+ return
+
+ QPlainTextEdit.keyPressEvent(self, e)
+ if self.isReadOnly(): # if field became read-only *after* keyPress, exit now
+ return
+
+ ctrlOrShift = ((Qt.KeyboardModifier.ControlModifier in e.modifiers())
+ or (Qt.KeyboardModifier.ShiftModifier in e.modifiers()))
+ if self.completer is None or (ctrlOrShift and not e.text()):
+ return
+
+ if not self.suggestions_enabled:
+ return
+
+ eow = "~!@#$%^&*()_+{}|:\"<>?,./;'[]\\-="
+ hasModifier = (e.modifiers() != Qt.KeyboardModifier.NoModifier) and not ctrlOrShift
+ completionPrefix = self.text_under_cursor()
+
+ if hasModifier or not e.text() or len(completionPrefix) < 1 or eow.find(e.text()[-1]) >= 0:
+ self.completer.popup().hide()
+ return
+
+ if completionPrefix != self.completer.completionPrefix():
+ self.completer.setCompletionPrefix(completionPrefix)
+ self.completer.popup().setCurrentIndex(self.completer.completionModel().index(0, 0))
+
+ cr = self.cursorRect()
+ cr.setWidth(self.completer.popup().sizeHintForColumn(0) + self.completer.popup().verticalScrollBar().sizeHint().width())
+ self.completer.complete(cr)
+
+ def is_special_key(self, e):
+ if self.completer and self.completer.popup().isVisible():
+ if e.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return):
+ return True
+ if e.key() == Qt.Key.Key_Tab:
+ return True
+ return False
+
+
+if __name__ == "__main__":
+ app = QApplication([])
+ completer = QCompleter(["alabama", "arkansas", "avocado", "breakfast", "sausage"])
+ te = CompletionTextEdit()
+ te.set_completer(completer)
+ te.show()
+ app.exec()
diff --git a/electrum/gui/qt/confirm_tx_dialog.py b/electrum/gui/qt/confirm_tx_dialog.py
new file mode 100644
index 000000000000..f58e03d316b6
--- /dev/null
+++ b/electrum/gui/qt/confirm_tx_dialog.py
@@ -0,0 +1,1243 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (2019) The Electrum Developers
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import asyncio
+from decimal import Decimal
+from functools import partial
+from typing import TYPE_CHECKING, Optional, Union, Sequence
+from concurrent.futures import Future
+from enum import Enum, auto
+
+from PyQt6.QtCore import Qt, QTimer, pyqtSlot, pyqtSignal
+from PyQt6.QtGui import QIcon
+from PyQt6.QtWidgets import (QHBoxLayout, QVBoxLayout, QLabel, QGridLayout, QPushButton, QToolButton,
+ QComboBox, QTabWidget, QWidget, QStackedWidget)
+
+from electrum.i18n import _
+from electrum.util import (UserCancelled, quantize_feerate, profiler, NotEnoughFunds, NoDynamicFeeEstimates,
+ get_asyncio_loop, wait_for2, UserFacingException)
+from electrum.plugin import run_hook
+from electrum.transaction import PartialTransaction, PartialTxOutput, Transaction
+from electrum.wallet import InternalAddressCorruption
+from electrum.bitcoin import DummyAddress
+from electrum.fee_policy import FeePolicy, FixedFeePolicy, FeeMethod
+from electrum.logging import Logger
+from electrum.submarine_swaps import NostrTransport, HttpTransport, SwapServerTransport, SwapServerError
+from electrum.gui.messages import MSG_SUBMARINE_PAYMENT_HELP_TEXT
+
+from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
+
+from .util import (WindowModalDialog, ColorScheme, HelpLabel, Buttons, CancelButton, WWLabel,
+ read_QIcon, IconLabel, HelpButton, RunCoroutineDialog)
+from .transaction_dialog import TxSizeLabel, TxFiatLabel, TxInOutWidget
+from .fee_slider import FeeSlider, FeeComboBox
+from .amountedit import FeerateEdit, BTCAmountEdit
+from .locktimeedit import LockTimeEdit
+from .my_treeview import QMenuWithConfig
+from .swap_dialog import SwapProvidersButton
+
+if TYPE_CHECKING:
+ from .main_window import ElectrumWindow
+
+
+class TxEditorContext(Enum):
+ """
+ Context for which the TxEditor gets launched.
+ Allows to enable/disable certain features.
+ """
+ PAYMENT = auto()
+ CHANNEL_FUNDING = auto()
+
+
+class TxEditor(WindowModalDialog, QtEventListener, Logger):
+
+ swap_availability_changed = pyqtSignal()
+
+ def __init__(
+ self, *, title='',
+ window: 'ElectrumWindow',
+ make_tx,
+ output_value: Union[int, str],
+ payee_outputs: Optional[list[PartialTxOutput]] = None,
+ context: TxEditorContext = TxEditorContext.PAYMENT,
+ batching_candidates: Sequence[Transaction] = None,
+ ):
+
+ WindowModalDialog.__init__(self, window, title=title)
+ Logger.__init__(self)
+ self.main_window = window
+ self.make_tx = make_tx
+ self.output_value = output_value
+ # used only for submarine payments as they construct tx independently of make_tx
+ self.payee_outputs = payee_outputs
+ self.tx = None # type: Optional[PartialTransaction]
+ self.messages = []
+ self.error = '' # set by side effect
+
+ self.config = window.config
+ self.network = window.network
+ self.fee_policy = FeePolicy(self.config.FEE_POLICY)
+ self.wallet = window.wallet
+ self.feerounding_sats = 0
+ self.not_enough_funds = False
+ self.no_dynfee_estimates = False
+ self.needs_update = False
+ self.context = context
+ self.is_preview = False
+ self._base_tx = None # type: Optional[Transaction] # for batching
+ self.batching_candidates = batching_candidates
+
+ self.swap_manager = self.wallet.lnworker.swap_manager if self.wallet.has_lightning() else None
+ self.swap_transport = None # type: Optional[SwapServerTransport]
+ self.swap_availability_changed.connect(self.on_swap_availability_changed, Qt.ConnectionType.QueuedConnection)
+ self.ongoing_swap_transport_connection_attempt = None # type: Optional[Future]
+ self.did_swap = False # used to clear the PI on send tab
+
+ self.locktime_e = LockTimeEdit(self)
+ self.locktime_e.valueEdited.connect(self.trigger_update)
+ self.locktime_label = QLabel(_("LockTime") + ": ")
+ self.io_widget = TxInOutWidget(self.main_window, self.wallet)
+ self.create_fee_controls()
+
+ onchain_vbox = QVBoxLayout()
+ onchain_top = self.create_top_bar(self.help_text)
+ onchain_grid = self.create_grid()
+ onchain_vbox.addLayout(onchain_top)
+ onchain_vbox.addLayout(onchain_grid)
+ onchain_vbox.addWidget(self.io_widget)
+ self.message_label = WWLabel('')
+ self.message_label.setMinimumHeight(70)
+ onchain_vbox.addWidget(self.message_label)
+
+ onchain_buttons = self.create_buttons_bar()
+ onchain_vbox.addStretch(1)
+ onchain_vbox.addLayout(onchain_buttons)
+
+ # onchain tab is the main tab and the content is also shown if tabs are disabled
+ self.onchain_tab = QWidget()
+ self.onchain_tab.setContentsMargins(0,0,0,0)
+ self.onchain_tab.setLayout(onchain_vbox)
+
+ # optional submarine payment tab, the tab is only shown if the option is enabled
+ self.submarine_payment_tab = self.create_submarine_payment_tab()
+
+ self.tab_widget = QTabWidget()
+ self.tab_widget.setTabBarAutoHide(True) # hides the tab bar if there is only one tab
+ self.tab_widget.setContentsMargins(0, 0, 0, 0)
+ self.tab_widget.currentChanged.connect(self.on_tab_changed)
+
+ self.main_layout = QVBoxLayout()
+ self.main_layout.addWidget(self.tab_widget)
+ self.main_layout.setContentsMargins(6, 6, 6, 6) # reduce outermost margins a bit
+ self.setLayout(self.main_layout)
+
+ self.set_io_visible()
+ self.set_fee_edit_visible()
+ self.set_locktime_visible()
+ self.update_fee_target()
+ self.update_tab_visibility()
+ self.resize_to_fit_content()
+
+ self.timer = QTimer(self)
+ self.timer.setInterval(500)
+ self.timer.setSingleShot(False)
+ self.timer.timeout.connect(self.timer_actions)
+ self.timer.start()
+ self.register_callbacks()
+ # debug_widget_layouts(self) # enable to show red lines around all elements
+
+ def accept(self):
+ self._cleanup()
+ super().accept()
+
+ def reject(self):
+ self._cleanup()
+ super().reject()
+
+ def closeEvent(self, event):
+ self._cleanup()
+ super().closeEvent(event)
+
+ def _cleanup(self):
+ self.unregister_callbacks()
+ if self.ongoing_swap_transport_connection_attempt:
+ self.ongoing_swap_transport_connection_attempt.cancel()
+ if isinstance(self.swap_transport, NostrTransport):
+ asyncio.run_coroutine_threadsafe(self.swap_transport.stop(), get_asyncio_loop())
+ self.swap_transport = None # HTTPTransport doesn't need to be closed
+
+ def on_tab_changed(self, index):
+ if self.tab_widget.widget(index) == self.submarine_payment_tab:
+ self.prepare_swap_transport()
+ self.update_submarine_payment_tab()
+ else:
+ self.update()
+
+ def is_batching(self) -> bool:
+ return self._base_tx is not None
+
+ def timer_actions(self):
+ if self.needs_update:
+ self.update()
+ self.needs_update = False
+
+ def update(self):
+ self.update_tx()
+ self.set_locktime()
+ self._update_widgets()
+
+ def stop_editor_updates(self):
+ self.timer.stop()
+
+ def update_tx(self, *, fallback_to_zero_fee: bool = False):
+ # expected to set self.tx, self.message and self.error
+ raise NotImplementedError()
+
+ def create_grid(self) -> QGridLayout:
+ raise NotImplementedError()
+
+ @property
+ def help_text(self) -> str:
+ raise NotImplementedError()
+
+ def update_fee_target(self):
+ if self.fee_slider.is_active():
+ text = self.fee_policy.get_target_text()
+ else:
+ text = ""
+ self.fee_target.setText(text)
+
+ def update_feerate_label(self):
+ self.feerate_label.setText(self.feerate_e.text() + ' ' + self.feerate_e.base_unit())
+
+ def create_fee_controls(self):
+
+ self.fee_label = QLabel('')
+ self.fee_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
+
+ self.size_label = TxSizeLabel()
+ self.size_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.size_label.setAmount(0)
+ self.size_label.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet())
+
+ self.feerate_label = QLabel('')
+ self.feerate_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
+
+ self.fiat_fee_label = TxFiatLabel()
+ self.fiat_fee_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.fiat_fee_label.setAmount(0)
+ self.fiat_fee_label.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet())
+
+ self.feerate_e = FeerateEdit(lambda: 0)
+ self.feerate_e.textEdited.connect(partial(self.on_fee_or_feerate, self.feerate_e, False))
+ self.feerate_e.editingFinished.connect(partial(self.on_fee_or_feerate, self.feerate_e, True))
+ self.update_feerate_label()
+
+ self.fee_e = BTCAmountEdit(self.main_window.get_decimal_point)
+ self.fee_e.textEdited.connect(partial(self.on_fee_or_feerate, self.fee_e, False))
+ self.fee_e.editingFinished.connect(partial(self.on_fee_or_feerate, self.fee_e, True))
+
+ self.feerate_e.setFixedWidth(150)
+ self.fee_e.setFixedWidth(150)
+
+ if self.fee_policy.method != FeeMethod.FIXED:
+ self.feerate_e.setAmount(self.fee_policy.fee_per_byte(self.network))
+ else:
+ self.fee_e.setAmount(self.fee_policy.value)
+
+ self.fee_e.textChanged.connect(self.entry_changed)
+ self.feerate_e.textChanged.connect(self.entry_changed)
+
+ self.fee_target = QLabel('')
+ self.fee_slider = FeeSlider(parent=self, network=self.network, fee_policy=self.fee_policy, callback=self.fee_slider_callback)
+ self.fee_combo = FeeComboBox(self.fee_slider)
+ self.fee_combo.setFocusPolicy(Qt.FocusPolicy.NoFocus)
+
+ def feerounding_onclick():
+ text = (self.feerounding_text() + '\n\n' +
+ _('To somewhat protect your privacy, Electrum tries to create change with similar precision to other outputs.') + ' ' +
+ _('At most 100 satoshis might be lost due to this rounding.') + ' ' +
+ _("You can disable this setting in '{}'.").format(_('Preferences')) + '\n' +
+ _('Also, dust is not kept as change, but added to the fee.') + '\n' +
+ _('Also, when batching RBF transactions, BIP 125 imposes a lower bound on the fee.'))
+ self.show_message(title=_('Fee rounding'), msg=text)
+
+ self.feerounding_icon = QToolButton()
+ self.feerounding_icon.setStyleSheet("background-color: rgba(255, 255, 255, 0); ")
+ self.feerounding_icon.setAutoRaise(True)
+ self.feerounding_icon.clicked.connect(feerounding_onclick)
+ self.set_feerounding_visibility(False)
+
+ self.fee_hbox = fee_hbox = QHBoxLayout()
+ fee_hbox.addWidget(self.feerate_e)
+ fee_hbox.addWidget(self.feerate_label)
+ fee_hbox.addWidget(self.size_label)
+ fee_hbox.addWidget(self.fee_e)
+ fee_hbox.addWidget(self.fee_label)
+ fee_hbox.addWidget(self.fiat_fee_label)
+ fee_hbox.addWidget(self.feerounding_icon)
+ fee_hbox.addStretch()
+
+ self.fee_target_hbox = fee_target_hbox = QHBoxLayout()
+ fee_target_hbox.addWidget(self.fee_target)
+ fee_target_hbox.addWidget(self.fee_slider)
+ fee_target_hbox.addWidget(self.fee_combo)
+ fee_target_hbox.addStretch()
+
+ # set feerate_label to same size as feerate_e
+ self.feerate_label.setFixedSize(self.feerate_e.sizeHint())
+ self.fee_label.setFixedSize(self.fee_e.sizeHint())
+ self.fee_slider.setFixedWidth(200)
+ self.fee_target.setFixedSize(self.feerate_e.sizeHint())
+
+ def update_tab_visibility(self):
+ """Update self.tab_widget to show all tabs that are enabled."""
+ # first remove all tabs
+ while self.tab_widget.count() > 0:
+ self.tab_widget.removeTab(0)
+
+ # always show onchain payment tab
+ self.tab_widget.addTab(self.onchain_tab, _('Onchain Transaction'))
+
+ allow_swaps = self.context == TxEditorContext.PAYMENT and self.payee_outputs and self.swap_manager
+ if self.config.WALLET_ENABLE_SUBMARINE_PAYMENTS and allow_swaps:
+ i = self.tab_widget.addTab(self.submarine_payment_tab, _('Submarine Payment'))
+ tooltip = self.config.cv.WALLET_ENABLE_SUBMARINE_PAYMENTS.get_long_desc()
+ if len(self.payee_outputs) > 1:
+ self.tab_widget.setTabEnabled(i, False)
+ tooltip = _("Submarine Payments don't support multiple outputs (Pay-to-many).")
+ elif self.payee_outputs[0].value == '!':
+ self.tab_widget.setTabEnabled(i, False)
+ self.submarine_payment_tab.setEnabled(False)
+ tooltip = _("Submarine Payments don't support 'Max' value spends.")
+ self.tab_widget.tabBar().setTabToolTip(i, tooltip)
+
+ # enable document mode if there is only one tab to hide the frame
+ self.tab_widget.setDocumentMode(self.tab_widget.count() < 2)
+ self.resize_to_fit_content()
+
+ def trigger_update(self):
+ # set tx to None so that the ok button is disabled while we compute the new tx
+ self.tx = None
+ self.messages = []
+ self.error = ''
+ self._update_widgets()
+ self.needs_update = True
+
+ def fee_slider_callback(self, fee_rate):
+ self.fee_slider.activate()
+ if fee_rate:
+ fee_rate = Decimal(fee_rate)
+ self.feerate_e.setAmount(quantize_feerate(fee_rate / 1000))
+ else:
+ self.feerate_e.setAmount(None)
+ self.fee_e.setModified(False)
+ self.update_fee_target()
+ self.update_feerate_label()
+ self.trigger_update()
+
+ def on_fee_or_feerate(self, edit_changed, editing_finished):
+ edit_other = self.feerate_e if edit_changed == self.fee_e else self.fee_e
+ if editing_finished:
+ if edit_changed.get_amount() is None:
+ # This is so that when the user blanks the fee and moves on,
+ # we go back to auto-calculate mode and put a fee back.
+ edit_changed.setModified(False)
+ else:
+ # edit_changed was edited just now, so make sure we will
+ # freeze the correct fee setting (this)
+ edit_other.setModified(False)
+ self.fee_slider.deactivate()
+ # do not call trigger_update on editing_finished,
+ # because that event is emitted when we press OK
+ self.trigger_update()
+
+ def is_send_fee_frozen(self) -> bool:
+ return self.fee_e.isVisible() and self.fee_e.isModified() \
+ and (bool(self.fee_e.text()) or self.fee_e.hasFocus())
+
+ def is_send_feerate_frozen(self) -> bool:
+ return self.feerate_e.isVisible() and self.feerate_e.isModified() \
+ and (bool(self.feerate_e.text()) or self.feerate_e.hasFocus())
+
+ def feerounding_text(self):
+ return (_('Additional {} satoshis are going to be added.').format(self.feerounding_sats))
+
+ def set_feerounding_visibility(self, b:bool):
+ # we do not use setVisible because it affects the layout
+ self.feerounding_icon.setIcon(read_QIcon('info.png') if b else QIcon())
+ self.feerounding_icon.setEnabled(b)
+
+ def get_fee_policy(self):
+ feerate = self.feerate_e.get_amount()
+ fee_amount = self.fee_e.get_amount()
+ if self.is_send_fee_frozen() and fee_amount is not None:
+ fee_policy = FixedFeePolicy(fee_amount)
+ elif self.is_send_feerate_frozen() and feerate is not None:
+ feerate_per_kb = int(feerate * 1000)
+ fee_policy = FeePolicy(f'feerate:{feerate_per_kb}')
+ else:
+ fee_policy = self.fee_slider.get_policy()
+ return fee_policy
+
+ def entry_changed(self):
+ # blue color denotes auto-filled values
+ text = ""
+ fee_color = ColorScheme.DEFAULT
+ feerate_color = ColorScheme.DEFAULT
+ if self.not_enough_funds:
+ fee_color = ColorScheme.RED
+ feerate_color = ColorScheme.RED
+ elif self.fee_e.isModified():
+ feerate_color = ColorScheme.BLUE
+ elif self.feerate_e.isModified():
+ fee_color = ColorScheme.BLUE
+ else:
+ fee_color = ColorScheme.BLUE
+ feerate_color = ColorScheme.BLUE
+ self.fee_e.setStyleSheet(fee_color.as_stylesheet())
+ self.feerate_e.setStyleSheet(feerate_color.as_stylesheet())
+ self.needs_update = True
+
+ def update_fee_fields(self):
+ freeze_fee = self.is_send_fee_frozen()
+ freeze_feerate = self.is_send_feerate_frozen()
+ tx = self.tx
+ if self.no_dynfee_estimates and tx:
+ size = tx.estimated_size()
+ self.size_label.setAmount(size)
+ #self.size_e.setAmount(size)
+ if self.not_enough_funds or self.no_dynfee_estimates:
+ if not freeze_fee:
+ self.fee_e.setAmount(None)
+ if not freeze_feerate:
+ self.feerate_e.setAmount(None)
+ self.set_feerounding_visibility(False)
+ return
+
+ assert tx is not None
+ size = tx.estimated_size()
+ fee = tx.get_fee()
+
+ #self.size_e.setAmount(size)
+ self.size_label.setAmount(size)
+ fiat_fee = self.main_window.format_fiat_and_units(fee)
+ self.fiat_fee_label.setAmount(fiat_fee)
+
+ # Displayed fee/fee_rate values are set according to user input.
+ # Due to rounding or dropping dust in CoinChooser,
+ # actual fees often differ somewhat.
+ if freeze_feerate or self.fee_slider.is_active():
+ displayed_feerate = self.feerate_e.get_amount()
+ if displayed_feerate is not None:
+ displayed_feerate = quantize_feerate(displayed_feerate)
+ elif self.fee_slider.is_active():
+ # fallback to actual fee
+ displayed_feerate = quantize_feerate(fee / size) if fee is not None else None
+ self.feerate_e.setAmount(displayed_feerate)
+ if displayed_feerate is not None:
+ displayed_fee = FeePolicy.estimate_fee_for_feerate(fee_per_kb=displayed_feerate * 1000, size=size)
+ else:
+ displayed_fee = None
+ self.fee_e.setAmount(displayed_fee)
+ else:
+ if freeze_fee:
+ displayed_fee = self.fee_e.get_amount()
+ else:
+ # fallback to actual fee if nothing is frozen
+ displayed_fee = fee
+ self.fee_e.setAmount(displayed_fee)
+ displayed_fee = displayed_fee if displayed_fee else 0
+ displayed_feerate = quantize_feerate(displayed_fee / size) if displayed_fee is not None else None
+ self.feerate_e.setAmount(displayed_feerate)
+
+ # set fee rounding icon to empty if there is no rounding
+ feerounding = (fee - displayed_fee) if (fee and displayed_fee is not None) else 0
+ self.feerounding_sats = int(feerounding)
+ self.feerounding_icon.setToolTip(self.feerounding_text())
+ self.set_feerounding_visibility(abs(feerounding) >= 1)
+ # feerate_label needs to be updated from feerate_e
+ self.update_feerate_label()
+ self.update_fee_target()
+
+ def create_buttons_bar(self):
+ self.change_to_ln_swap_providers_button = SwapProvidersButton(lambda: self.swap_transport, self.config, self.main_window)
+ self.preview_button = QPushButton(_('Preview'))
+ self.preview_button.clicked.connect(self.on_preview)
+ self.preview_button.setVisible(self.context != TxEditorContext.CHANNEL_FUNDING)
+ self.ok_button = QPushButton(_('OK'))
+ self.ok_button.clicked.connect(self.on_send)
+ self.ok_button.setDefault(True)
+ buttons = Buttons(CancelButton(self), self.preview_button, self.ok_button)
+ buttons.insertWidget(0, self.change_to_ln_swap_providers_button)
+
+ if self.batching_candidates is not None and len(self.batching_candidates) > 0:
+ batching_combo = QComboBox()
+ batching_combo.addItems([_('Do not batch')] + [_('Batch with') + ' ' + tx.txid()[0:10] for tx in self.batching_candidates])
+ buttons.insertWidget(0, batching_combo)
+ def on_batching_combo(x):
+ self._base_tx = self.batching_candidates[x - 1] if x > 0 else None
+ self.trigger_update()
+ batching_combo.currentIndexChanged.connect(on_batching_combo)
+ return buttons
+
+ def create_top_bar(self, text):
+ self.pref_menu = QMenuWithConfig(self.config)
+
+ def cb():
+ self.set_io_visible()
+ self.resize_to_fit_content()
+ self.pref_menu.addConfig(self.config.cv.GUI_QT_TX_EDITOR_SHOW_IO, callback=cb)
+ def cb():
+ self.set_fee_edit_visible()
+ self.resize_to_fit_content()
+ self.pref_menu.addConfig(self.config.cv.GUI_QT_TX_EDITOR_SHOW_FEE_DETAILS, callback=cb)
+ def cb():
+ self.set_locktime_visible()
+ self.resize_to_fit_content()
+ self.pref_menu.addConfig(self.config.cv.GUI_QT_TX_EDITOR_SHOW_LOCKTIME, callback=cb)
+ self.pref_menu.addSeparator()
+ can_have_lightning = self.wallet.can_have_lightning()
+ send_ch_to_ln = self.pref_menu.addConfig(
+ self.config.cv.WALLET_SEND_CHANGE_TO_LIGHTNING,
+ callback=lambda: (self.prepare_swap_transport(), self.trigger_update()), # type: ignore
+ checked=False if not can_have_lightning else None,
+ )
+ sub_payments = self.pref_menu.addConfig(
+ self.config.cv.WALLET_ENABLE_SUBMARINE_PAYMENTS,
+ callback=self.update_tab_visibility,
+ checked=False if not can_have_lightning else None,
+ )
+ if not can_have_lightning: # disable the buttons and override tooltip
+ ln_unavailable_msg = _("Not available for this wallet.") \
+ + "\n" + _("Requires a wallet with Lightning network support.")
+ for ln_conf in (send_ch_to_ln, sub_payments):
+ ln_conf.setEnabled(False)
+ ln_conf.setToolTip(ln_unavailable_msg)
+ self.pref_menu.addToggle(
+ _('Use change addresses'),
+ self.toggle_use_change,
+ default_state=self.wallet.use_change,
+ tooltip=_('Using change addresses makes it more difficult for other people to track your transactions.'))
+ self.use_multi_change_menu = self.pref_menu.addToggle(
+ _('Use multiple change addresses'),
+ self.toggle_multiple_change,
+ default_state=self.wallet.multiple_change,
+ tooltip='\n'.join([
+ _('In some cases, use up to 3 change addresses in order to break '
+ 'up large coin amounts and obfuscate the recipient address.'),
+ _('This may result in higher transactions fees.')
+ ]))
+ self.use_multi_change_menu.setEnabled(self.wallet.use_change)
+ # fixme: some of these options (WALLET_SEND_CHANGE_TO_LIGHTNING, WALLET_MERGE_DUPLICATE_OUTPUTS)
+ # only make sense when we create a new tx, and should not be visible/enabled in rbf dialog
+ self.pref_menu.addConfig(self.config.cv.WALLET_MERGE_DUPLICATE_OUTPUTS, callback=self.trigger_update)
+ self.pref_menu.addConfig(self.config.cv.WALLET_SPEND_CONFIRMED_ONLY, callback=self.trigger_update)
+ self.pref_menu.addConfig(self.config.cv.WALLET_COIN_CHOOSER_OUTPUT_ROUNDING, callback=self.trigger_update)
+ self.pref_button = QToolButton()
+ self.pref_button.setIcon(read_QIcon("preferences.png"))
+ self.pref_button.setText(_('Tools'))
+ self.pref_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
+ self.pref_button.setMenu(self.pref_menu)
+ self.pref_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
+ self.pref_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
+ hbox = QHBoxLayout()
+ hbox.addWidget(QLabel(text))
+ hbox.addStretch()
+ hbox.addWidget(self.pref_button)
+ return hbox
+
+ @profiler(min_threshold=0.02)
+ def resize_to_fit_content(self):
+ # update all geometries so the updated size hints are used for size adjustment
+ for widget in self.findChildren(QWidget):
+ widget.updateGeometry()
+ self.adjustSize()
+
+ def toggle_use_change(self):
+ self.wallet.use_change = not self.wallet.use_change
+ self.wallet.db.put('use_change', self.wallet.use_change)
+ self.use_multi_change_menu.setEnabled(self.wallet.use_change)
+ self.trigger_update()
+
+ def toggle_multiple_change(self):
+ self.wallet.multiple_change = not self.wallet.multiple_change
+ self.wallet.db.put('multiple_change', self.wallet.multiple_change)
+ self.trigger_update()
+
+ def set_io_visible(self):
+ self.io_widget.setVisible(self.config.GUI_QT_TX_EDITOR_SHOW_IO)
+
+ def set_fee_edit_visible(self):
+ b = self.config.GUI_QT_TX_EDITOR_SHOW_FEE_DETAILS
+ detailed = [self.feerounding_icon, self.feerate_e, self.fee_e]
+ basic = [self.fee_label, self.feerate_label]
+ # first hide, then show
+ for w in (basic if b else detailed):
+ w.hide()
+ for w in (detailed if b else basic):
+ w.show()
+
+ def set_locktime_visible(self):
+ b = self.config.GUI_QT_TX_EDITOR_SHOW_LOCKTIME
+ for w in [
+ self.locktime_e,
+ self.locktime_label]:
+ w.setVisible(b)
+
+ def run(self):
+ if self.config.WALLET_SEND_CHANGE_TO_LIGHTNING:
+ # if disabled but submarine payments are enabled we only connect once the other tab gets opened
+ self.prepare_swap_transport()
+ cancelled = not self.exec()
+ self.stop_editor_updates()
+ self.deleteLater() # see #3956
+ return self.tx if not cancelled else None
+
+ def on_send(self):
+ if self.tx and self.tx.get_dummy_output(DummyAddress.SWAP):
+ if not self.request_forward_swap():
+ return
+ self.accept()
+
+ def on_preview(self):
+ assert not self.tx.get_dummy_output(DummyAddress.SWAP), "no preview when sending change to ln"
+ self.is_preview = True
+ self.accept()
+
+ def _update_widgets(self):
+ # side effect: self.error
+ self._update_amount_label()
+ if self.not_enough_funds:
+ self.error = _('Not enough funds.')
+ confirmed_only = self.config.WALLET_SPEND_CONFIRMED_ONLY
+ if confirmed_only and self.can_pay_assuming_zero_fees(confirmed_only=False):
+ self.error += ' ' + _('Change your settings to allow spending unconfirmed coins.')
+ elif self.can_pay_assuming_zero_fees(confirmed_only=confirmed_only):
+ self.error += ' ' + _('You need to set a lower fee.')
+ elif frozen_bal := self.wallet.get_frozen_balance_str():
+ self.error = self.wallet.get_text_not_enough_funds_mentioning_frozen(
+ for_amount=self.output_value,
+ hint=_('Can be unfrozen in the Addresses or in the Coins tab')
+ )
+ if not self.tx:
+ if self.not_enough_funds:
+ self.io_widget.update(None)
+ self.set_feerounding_visibility(False)
+ self.messages = [_('Preparing transaction...')]
+ else:
+ self.messages = self.get_messages()
+ self.update_fee_fields()
+ if self.locktime_e.get_locktime() is None:
+ self.locktime_e.set_locktime(self.tx.locktime)
+ self.io_widget.update(self.tx)
+ self.fee_label.setText(self.main_window.config.format_amount_and_units(self.tx.get_fee()))
+ self._update_extra_fees()
+
+ if self.config.WALLET_SEND_CHANGE_TO_LIGHTNING:
+ self.change_to_ln_swap_providers_button.setVisible(True)
+ self.change_to_ln_swap_providers_button.fetching = bool(self.ongoing_swap_transport_connection_attempt)
+ self.change_to_ln_swap_providers_button.update()
+ else:
+ self.change_to_ln_swap_providers_button.setVisible(False)
+
+ self._update_send_button()
+ self._update_message()
+
+ def get_messages(self):
+ # side effect: self.error
+ messages = []
+ fee = self.tx.get_fee()
+ assert fee is not None
+ amount = self.tx.output_value() if self.output_value == '!' else self.output_value
+ tx_size = self.tx.estimated_size()
+ fee_warning_tuple = self.wallet.get_tx_fee_warning(
+ invoice_amt=amount, tx_size=tx_size, fee=fee, txid=self.tx.txid())
+ if fee_warning_tuple:
+ allow_send, long_warning, short_warning = fee_warning_tuple
+ if not allow_send:
+ self.error = long_warning
+ else:
+ messages.append(long_warning)
+ if self.no_dynfee_estimates:
+ self.error = _('Fee estimates not available. Please set a fixed fee or feerate.')
+ if dummy_output := self.tx.get_dummy_output(DummyAddress.SWAP):
+ swap_msg = _('Will send change to lightning')
+ swap_fee_msg = "."
+ if self.swap_manager and self.swap_manager.is_initialized.is_set() and isinstance(dummy_output.value, int):
+ ln_amount_we_recv = self.swap_manager.get_recv_amount(send_amount=dummy_output.value, is_reverse=False)
+ if ln_amount_we_recv:
+ swap_fees = dummy_output.value - ln_amount_we_recv
+ swap_fee_msg = " [" + _("Swap fees:") + " " + self.main_window.format_amount_and_units(swap_fees) + "]."
+ messages.append(swap_msg + swap_fee_msg)
+ elif self.config.WALLET_SEND_CHANGE_TO_LIGHTNING \
+ and not self.ongoing_swap_transport_connection_attempt \
+ and self.tx.has_change():
+ swap_msg = _('Will not send change to Lightning')
+ swap_msg_reason = None
+ change_amount = sum(c.value for c in self.tx.get_change_outputs() if isinstance(c.value, int))
+ if not self.wallet.has_lightning():
+ swap_msg_reason = _('Lightning is not enabled.')
+ elif change_amount > int(self.wallet.lnworker.num_sats_can_receive()):
+ swap_msg_reason = _("Your channels cannot receive this amount.")
+ elif self.wallet.lnworker.swap_manager.is_initialized.is_set():
+ min_amount = self.wallet.lnworker.swap_manager.get_min_amount()
+ max_amount = self.wallet.lnworker.swap_manager.get_provider_max_reverse_amount()
+ if change_amount < min_amount:
+ swap_msg_reason = _("Below the swap providers minimum value of {}.").format(
+ self.main_window.format_amount_and_units(min_amount)
+ )
+ else:
+ swap_msg_reason = _('Change amount exceeds the swap providers maximum value of {}.').format(
+ self.main_window.format_amount_and_units(max_amount)
+ )
+ messages.append(swap_msg + (f": {swap_msg_reason}" if swap_msg_reason else '.'))
+ elif self.ongoing_swap_transport_connection_attempt:
+ messages.append(_("Fetching submarine swap providers..."))
+ # warn if spending unconf
+ if any((txin.block_height is not None and txin.block_height<=0) for txin in self.tx.inputs()):
+ messages.append(_('This transaction will spend unconfirmed coins.'))
+ # warn if a reserve utxo was added
+ if reserve_sats := self.wallet.tx_keeps_ln_utxo_reserve(self.tx, gui_spend_max=bool(self.output_value == '!')):
+ reserve_str = self.main_window.config.format_amount_and_units(reserve_sats)
+ messages.append(_('Could not spend max: a security reserve of {} was kept for your Lightning channels.').format(reserve_str))
+ # warn if we merge from mempool
+ if self.is_batching():
+ messages.append(_('This payment will be merged with another existing transaction.'))
+ # warn if we use multiple change outputs
+ num_change = sum(int(o.is_change) for o in self.tx.outputs())
+ num_ismine = sum(int(o.is_mine) for o in self.tx.outputs())
+ if num_change > 1:
+ messages.append(_('This transaction has {} change outputs.'.format(num_change)))
+ # warn if there is no ismine output, as it might be problematic to RBF the tx later.
+ # (though RBF is still possible by adding new inputs, if the wallet has more utxos)
+ if num_ismine == 0:
+ messages.append(_('Make sure you pay enough mining fees; you will not be able to bump the fee later.'))
+
+ # TODO: warn if we send change back to input address
+ return messages
+
+ def set_locktime(self):
+ if not self.tx:
+ return
+ locktime = self.locktime_e.get_locktime()
+ if locktime is not None:
+ self.tx.locktime = locktime
+
+ def _update_amount_label(self):
+ pass
+
+ def _update_extra_fees(self):
+ pass
+
+ def _update_message(self):
+ style = ColorScheme.RED if self.error else ColorScheme.BLUE
+ message_str = '\n'.join(self.messages) if self.messages else ''
+ self.message_label.setStyleSheet(style.as_stylesheet())
+ self.message_label.setText(self.error or message_str)
+
+ def _update_send_button(self):
+ # disable preview button when sending change to lightning to prevent the user from saving or
+ # exporting the transaction and broadcasting it later somehow.
+ send_change_to_ln = self.tx and self.tx.get_dummy_output(DummyAddress.SWAP)
+ enabled = bool(self.tx) and not self.error
+ self.preview_button.setEnabled(enabled and not send_change_to_ln)
+ self.preview_button.setToolTip(_("Can't show preview when sending change to lightning") if send_change_to_ln else "")
+ self.ok_button.setEnabled(enabled)
+
+ def can_pay_assuming_zero_fees(self, confirmed_only: bool) -> bool:
+ raise NotImplementedError
+
+ ### --- Shared functionality for submarine swaps (change to ln and submarine payments) ---
+ def prepare_swap_transport(self):
+ if not self.swap_manager:
+ return # no swaps possible, lightning disabled
+ if self.swap_transport is not None and self.swap_transport.is_connected.is_set():
+ # we already have a connected transport, no need to create a new one
+ return
+ if self.ongoing_swap_transport_connection_attempt:
+ # another task is currently trying to connect
+ return
+
+ # there should only be a connected transport.
+ # a useless transport should get cleaned up and not stored.
+ assert self.swap_transport is None, "swap transport wasn't cleaned up properly"
+
+ new_swap_transport = self.main_window.create_sm_transport()
+ if not new_swap_transport:
+ # user declined to enable Nostr and has no http server configured
+ self.swap_availability_changed.emit()
+ return
+
+ async def _initialize_transport(transport):
+ try:
+ if isinstance(transport, NostrTransport):
+ asyncio.create_task(transport.main_loop())
+ else:
+ assert isinstance(transport, HttpTransport)
+ asyncio.create_task(transport.get_pairs_just_once())
+ if not await self.wait_for_swap_transport(transport):
+ return
+ self.swap_transport = transport
+ except Exception:
+ self.logger.exception("failed to create swap transport")
+ finally:
+ self.ongoing_swap_transport_connection_attempt = None
+ self.swap_availability_changed.emit()
+
+ # this task will get cancelled if the TxEditor gets closed
+ self.ongoing_swap_transport_connection_attempt = asyncio.run_coroutine_threadsafe(
+ _initialize_transport(new_swap_transport),
+ get_asyncio_loop(),
+ )
+
+ async def wait_for_swap_transport(self, new_swap_transport: Union[HttpTransport, NostrTransport]) -> bool:
+ """
+ Wait until we found the announcement event of the configured swap server.
+ If it is not found but the relay connection is established return True anyway,
+ the user will then need to select a different swap server.
+ """
+ timeout = new_swap_transport.connect_timeout + 1
+ try:
+ # swap_manager.is_initialized gets set once we got pairs of the configured swap server
+ await wait_for2(self.swap_manager.is_initialized.wait(), timeout)
+ except asyncio.TimeoutError:
+ self.logger.debug(f"swap transport initialization timed out after {timeout} sec")
+
+ if self.swap_manager.is_initialized.is_set():
+ return True
+
+ # timed out above
+ if self.config.SWAPSERVER_URL:
+ # http swapserver didn't return pairs
+ self.logger.error(f"couldn't request pairs from {self.config.SWAPSERVER_URL=}")
+ return False
+ elif new_swap_transport.is_connected.is_set():
+ assert isinstance(new_swap_transport, NostrTransport)
+ # couldn't find announcement of configured swapserver, maybe it is gone.
+ # update_submarine_payment_tab will tell the user to select a different swap server.
+ return True
+
+ # we couldn't even connect to the relays, this transport is useless. maybe network issues.
+ return False
+
+ @qt_event_listener
+ def on_event_swap_provider_changed(self):
+ self.swap_availability_changed.emit()
+
+ @qt_event_listener
+ def on_event_channel(self, wallet, _channel):
+ # useful e.g. if the user quickly opens the tab after startup before the channels are initialized
+ if wallet == self.wallet and self.swap_manager and self.swap_manager.is_initialized.is_set():
+ self.swap_availability_changed.emit()
+
+ @qt_event_listener
+ def on_event_swap_offers_changed(self, _):
+ self.change_to_ln_swap_providers_button.update()
+ self.submarine_payment_provider_button.update()
+ if self.ongoing_swap_transport_connection_attempt:
+ return
+ self.swap_availability_changed.emit()
+
+ @pyqtSlot()
+ def on_swap_availability_changed(self):
+ # uses a signal/slot to update the gui so we can schedule an update from the asyncio thread
+ if self.tab_widget.currentWidget() == self.submarine_payment_tab:
+ self.update_submarine_payment_tab()
+ else:
+ self.update()
+
+ ### --- Functionality for reverse submarine swaps to external address ---
+ def create_submarine_payment_tab(self) -> QWidget:
+ """Returns widget for submarine payment functionality to be added as tab"""
+ tab_widget = QWidget()
+ vbox = QVBoxLayout(tab_widget)
+
+ # stack two views, a warning view and the regular one. The warning view is shown if
+ # the swap cannot be performed, e.g. due to missing liquidity.
+ self.submarine_stacked_widget = QStackedWidget()
+
+ # Normal layout page
+ normal_page = QWidget()
+ h = QGridLayout(normal_page)
+ help_button = HelpButton(MSG_SUBMARINE_PAYMENT_HELP_TEXT)
+ self.submarine_lightning_send_amount_label = QLabel()
+ self.submarine_onchain_send_amount_label = QLabel()
+ self.submarine_claim_mining_fee_label = QLabel()
+ self.submarine_server_fee_label = QLabel()
+ self.submarine_we_send_label = IconLabel(text=_('You send')+':')
+ self.submarine_we_send_label.setIcon(read_QIcon('lightning.png'))
+ self.submarine_they_receive_label = IconLabel(text=_('They receive')+':')
+ self.submarine_they_receive_label.setIcon(read_QIcon('bitcoin.png'))
+ # column 0 (labels)
+ h.addWidget(self.submarine_we_send_label, 0, 0)
+ h.addWidget(self.submarine_they_receive_label, 1, 0)
+ h.addWidget(QLabel(_('Swap fee')+':'), 2, 0)
+ h.addWidget(QLabel(_('Mining fee')+':'), 3, 0)
+ # column 1 (spacing)
+ h.setColumnStretch(1, 1)
+ # column 2 (amounts)
+ h.addWidget(self.submarine_lightning_send_amount_label, 0, 2)
+ h.addWidget(self.submarine_onchain_send_amount_label, 1, 2)
+ h.addWidget(self.submarine_server_fee_label, 2, 2, 1, 2)
+ h.addWidget(self.submarine_claim_mining_fee_label, 3, 2, 1, 2)
+ # column 3 (spacing)
+ h.setColumnStretch(3, 1)
+ # column 4 (help button)
+ h.addWidget(help_button, 0, 4)
+
+ # Warning layout page
+ warning_page = QWidget()
+ warning_layout = QVBoxLayout(warning_page)
+ self.submarine_warning_label = QLabel('')
+ warning_layout.addWidget(self.submarine_warning_label)
+
+ self.submarine_stacked_widget.addWidget(normal_page)
+ self.submarine_stacked_widget.addWidget(warning_page)
+
+ vbox.addWidget(self.submarine_stacked_widget)
+ vbox.addStretch(1)
+
+ self.submarine_payment_provider_button = SwapProvidersButton(lambda: self.swap_transport, self.config, self.main_window)
+
+ self.submarine_ok_button = QPushButton(_('OK'))
+ self.submarine_ok_button.setDefault(True)
+ self.submarine_ok_button.setEnabled(False)
+ # pay button must not self.accept() as this triggers closing the transport
+ self.submarine_ok_button.clicked.connect(self.start_submarine_payment)
+
+ buttons = Buttons(CancelButton(self), self.submarine_ok_button)
+ buttons.insertWidget(0, self.submarine_payment_provider_button)
+ vbox.addLayout(buttons)
+
+ return tab_widget
+
+ def show_swap_transport_connection_message(self):
+ self.submarine_stacked_widget.setCurrentIndex(1)
+ self.submarine_warning_label.setText(_("Connecting, please wait..."))
+ self.submarine_ok_button.setEnabled(False)
+
+ def start_submarine_payment(self):
+ assert self.payee_outputs and len(self.payee_outputs) == 1
+ payee_output = self.payee_outputs[0]
+
+ assert self.expected_onchain_amount_sat is not None
+ assert self.lightning_send_amount_sat is not None
+ assert self.last_server_mining_fee_sat is not None
+ assert self.swap_transport.is_connected.is_set()
+ assert self.swap_manager.is_initialized.is_set()
+
+ self.tx = None # prevent broadcasting
+ self.submarine_ok_button.setEnabled(False)
+ coro = self.swap_manager.reverse_swap(
+ transport=self.swap_transport,
+ lightning_amount_sat=self.lightning_send_amount_sat,
+ expected_onchain_amount_sat=self.expected_onchain_amount_sat,
+ prepayment_sat=2 * self.last_server_mining_fee_sat,
+ claim_to_output=payee_output,
+ )
+ try:
+ funding_txid = self.main_window.run_coroutine_dialog(coro, _('Initiating Submarine Payment...'))
+ except Exception as e:
+ self.close()
+ self.main_window.show_error(_("Submarine Payment failed:") + "\n" + str(e))
+ return
+ self.did_swap = True
+ # accepting closes the swap transport, so it needs to happen after the swap
+ self.accept()
+ self.main_window.on_swap_result(funding_txid, is_reverse=True)
+
+ def update_submarine_payment_tab(self):
+ assert self.tab_widget.currentWidget() == self.submarine_payment_tab
+ assert self.payee_outputs, "Opened submarine payment tab without outputs?"
+ assert len(self.payee_outputs) == \
+ len([o for o in self.payee_outputs if not o.is_change and not isinstance(o.value, str)])
+ f = self.main_window.format_amount_and_units
+ self.logger.debug(f"TxEditor updating submarine payment tab")
+
+ if not self.swap_manager:
+ self.set_submarine_payment_tab_warning(_("Enable Lightning in the 'Channels' tab to use Submarine Swaps."))
+ return
+ if not self.swap_manager.is_initialized.is_set() \
+ and self.ongoing_swap_transport_connection_attempt:
+ self.show_swap_transport_connection_message()
+ return
+ if not self.swap_transport:
+ # couldn't connect to nostr relays or http server didn't respond
+ self.set_submarine_payment_tab_warning(_("Submarine swap provider unavailable."))
+ return
+
+ # Update the swapserver selection button text
+ self.submarine_payment_provider_button.update()
+
+ if not self.swap_manager.is_initialized.is_set():
+ # connected to nostr relays but couldn't find swapserver announcement
+ assert isinstance(self.swap_transport, NostrTransport), "HTTPTransport shouldn't get set if it cannot fetch pairs"
+ assert self.swap_transport.is_connected.is_set(), "closed transport wasn't cleaned up"
+ if self.config.SWAPSERVER_NPUB:
+ msg = _("Couldn't connect to your swap provider. Please select a different provider.")
+ else:
+ msg = _('Please select a submarine swap provider.')
+ self.set_submarine_payment_tab_warning(msg)
+ return
+
+ # update values
+ self.lightning_send_amount_sat = self.swap_manager.get_send_amount(
+ self.payee_outputs[0].value, # claim tx fee reserve gets added in get_send_amount
+ is_reverse=True,
+ )
+ self.last_server_mining_fee_sat = self.swap_manager.mining_fee
+ self.expected_onchain_amount_sat = (
+ self.payee_outputs[0].value + self.swap_manager.get_fee_for_txbatcher()
+ )
+
+ # get warning
+ warning_text = self.get_swap_warning()
+ if warning_text:
+ self.set_submarine_payment_tab_warning(warning_text)
+ return
+
+ # There is no warning, show the normal view (amounts etc.)
+ self.submarine_stacked_widget.setCurrentIndex(0)
+
+ # label showing the payment amount (the amount the user entered in SendTab)
+ self.submarine_onchain_send_amount_label.setText(f(self.payee_outputs[0].value))
+
+ # the fee we pay to claim the funding output to the onchain address, shown as "Mining Fee"
+ claim_tx_mining_fee = self.swap_manager.get_fee_for_txbatcher()
+ self.submarine_claim_mining_fee_label.setText(f(claim_tx_mining_fee))
+
+ assert self.lightning_send_amount_sat is not None
+ self.submarine_lightning_send_amount_label.setText(f(self.lightning_send_amount_sat))
+ # complete fee we pay to the server
+ server_fee = self.lightning_send_amount_sat - self.expected_onchain_amount_sat
+ self.submarine_server_fee_label.setText(f(server_fee))
+
+ self.submarine_ok_button.setEnabled(True)
+
+ def get_swap_warning(self) -> Optional[str]:
+ f = self.main_window.format_amount_and_units
+ ln_can_send = int(self.wallet.lnworker.num_sats_can_send())
+
+ if self.expected_onchain_amount_sat < self.swap_manager.get_min_amount():
+ return '\n'.join([
+ _("Payment amount below the minimum possible swap amount."),
+ _("Minimum amount: {}").format(f(self.swap_manager.get_min_amount())), "",
+ _("You need to send a higher amount to be able to do a Submarine Payment."),
+ ])
+
+ too_low_outbound_liquidity_msg = ''.join([
+ _("You don't have enough outgoing capacity in your lightning channels."), '\n',
+ _("Your lightning channels can send: {}").format(f(ln_can_send)), '\n',
+ _("For this transaction you need: {}").format(f(self.lightning_send_amount_sat)) if self.lightning_send_amount_sat else '',
+ '\n\n' if self.lightning_send_amount_sat else '\n',
+ _("To add outgoing capacity you can open a new lightning channel or do a submarine swap."),
+ ])
+
+ # prioritize showing the swap provider liquidity warning before the channel liquidity warning
+ # as it could be annoying for the user to be told to open a new channel just to come back to
+ # notice there is no provider supporting their swap amount
+ if self.lightning_send_amount_sat is None:
+ provider_liquidity = self.swap_manager.get_provider_max_forward_amount()
+ if provider_liquidity < self.swap_manager.get_min_amount():
+ provider_liquidity = 0
+ msg = [
+ _("The selected swap provider is unable to offer a forward swap of this value."),
+ _("Available liquidity") + f": {f(provider_liquidity)}", "",
+ _("In order to continue select a different provider or try to send a smaller amount."),
+ ]
+ # we don't know exactly how much we need to send on ln yet, so we can assume 0 provider fees
+ probably_too_low_outbound_liquidity = self.expected_onchain_amount_sat > ln_can_send
+ if probably_too_low_outbound_liquidity:
+ msg.extend([
+ "",
+ "Please also note:",
+ too_low_outbound_liquidity_msg,
+ ])
+ return "\n".join(msg)
+
+ # if we have lightning_send_amount_sat our provider has enough liquidity, so we know the exact
+ # amount we need to send including the providers fees
+ too_low_outbound_liquidity = self.lightning_send_amount_sat > ln_can_send
+ if too_low_outbound_liquidity:
+ return too_low_outbound_liquidity_msg
+
+ return None
+
+ def set_submarine_payment_tab_warning(self, warning: str):
+ msg = _('Submarine Payment not possible:') + '\n' + warning
+ self.submarine_warning_label.setText(msg)
+ self.submarine_stacked_widget.setCurrentIndex(1)
+ self.submarine_ok_button.setEnabled(False)
+
+ # --- send change to lightning swap functionality ---
+ def request_forward_swap(self):
+ swap_dummy_output = self.tx.get_dummy_output(DummyAddress.SWAP)
+ sm, transport = self.swap_manager, self.swap_transport
+ assert sm and transport and swap_dummy_output and isinstance(swap_dummy_output.value, int)
+ coro = sm.request_swap_for_amount(transport=transport, onchain_amount=int(swap_dummy_output.value))
+ coro_dialog = RunCoroutineDialog(self, _('Requesting swap invoice...'), coro)
+ try:
+ swap, swap_invoice = coro_dialog.run()
+ except (SwapServerError, UserFacingException) as e:
+ self.show_error(str(e))
+ return False
+ except UserCancelled:
+ return False
+ self.tx.replace_output_address(DummyAddress.SWAP, swap.lockup_address)
+ assert self.tx.get_dummy_output(DummyAddress.SWAP) is None
+ self.tx.swap_invoice = swap_invoice
+ self.tx.swap_payment_hash = swap.payment_hash
+ return True
+
+
+class ConfirmTxDialog(TxEditor):
+ help_text = '' #_('Set the mining fee of your transaction')
+
+ def __init__(
+ self, *,
+ window: 'ElectrumWindow',
+ make_tx,
+ output_value: Union[int, str],
+ payee_outputs: Optional[list[PartialTxOutput]] = None,
+ context: TxEditorContext = TxEditorContext.PAYMENT,
+ batching_candidates: Sequence[Transaction] = None,
+ ):
+
+ TxEditor.__init__(
+ self,
+ window=window,
+ make_tx=make_tx,
+ output_value=output_value,
+ payee_outputs=payee_outputs,
+ title=_("New Transaction"), # todo: adapt title for channel funding tx, swaps
+ context=context,
+ batching_candidates=batching_candidates,
+ )
+ self.trigger_update()
+
+ 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_tx(self, *, fallback_to_zero_fee: bool = False):
+ self.fee_policy = fee_policy = self.get_fee_policy()
+ if fee_policy.method != FeeMethod.FIXED:
+ self.config.FEE_POLICY = fee_policy.get_descriptor()
+ confirmed_only = self.config.WALLET_SPEND_CONFIRMED_ONLY
+ base_tx = self._base_tx
+ try:
+ self.tx = self.make_tx(fee_policy, confirmed_only=confirmed_only, base_tx=base_tx)
+ self.not_enough_funds = False
+ self.no_dynfee_estimates = False
+ except NotEnoughFunds:
+ self.not_enough_funds = True
+ self.tx = None
+ if fallback_to_zero_fee:
+ try:
+ self.tx = self.make_tx(FixedFeePolicy(0), confirmed_only=confirmed_only, base_tx=base_tx)
+ except BaseException:
+ return
+ else:
+ return
+ except NoDynamicFeeEstimates:
+ # is this still needed?
+ self.no_dynfee_estimates = True
+ self.tx = None
+ try:
+ self.tx = self.make_tx(FixedFeePolicy(0), confirmed_only=confirmed_only, base_tx=base_tx)
+ except NotEnoughFunds:
+ self.not_enough_funds = True
+ return
+ except BaseException:
+ return
+ except InternalAddressCorruption as e:
+ self.tx = None
+ self.main_window.show_error(str(e))
+ raise
+ self.tx.set_rbf(True)
+
+ def can_pay_assuming_zero_fees(self, confirmed_only: bool) -> bool:
+ # called in send_tab.py
+ try:
+ tx = self.make_tx(FixedFeePolicy(0), confirmed_only=confirmed_only, base_tx=None)
+ except NotEnoughFunds:
+ return False
+ else:
+ return True
+
+ def create_grid(self):
+ grid = QGridLayout()
+ msg = (_('The amount to be received by the recipient.') + ' '
+ + _('Fees are paid by the sender.'))
+ self.amount_label = QLabel('')
+ self.amount_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
+
+ grid.addWidget(HelpLabel(_("Amount to be sent") + ": ", msg), 0, 0)
+ grid.addWidget(self.amount_label, 0, 1)
+
+ msg = _('Bitcoin transactions are in general not free. A transaction fee is paid by the sender of the funds.') + '\n\n'\
+ + _('The amount of fee can be decided freely by the sender. However, transactions with low fees take more time to be processed.') + '\n\n'\
+ + _('A suggested fee is automatically added to this field. You may override it. The suggested fee increases with the size of the transaction.')
+
+ grid.addWidget(HelpLabel(_("Mining Fee") + ": ", msg), 1, 0)
+ grid.addLayout(self.fee_hbox, 1, 1, 1, 3)
+
+ grid.addWidget(HelpLabel(_("Fee policy") + ": ", self.fee_combo.help_msg), 3, 0)
+ grid.addLayout(self.fee_target_hbox, 3, 1, 1, 3)
+
+ grid.setColumnStretch(4, 1)
+
+ # extra fee
+ self.extra_fee_label = QLabel(_("Additional fees") + ": ")
+ self.extra_fee_label.setVisible(False)
+ self.extra_fee_value = QLabel('')
+ self.extra_fee_value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
+ self.extra_fee_value.setVisible(False)
+ grid.addWidget(self.extra_fee_label, 5, 0)
+ grid.addWidget(self.extra_fee_value, 5, 1)
+
+ # locktime editor
+ grid.addWidget(self.locktime_label, 6, 0)
+ grid.addWidget(self.locktime_e, 6, 1, 1, 2)
+
+ return grid
+
+ def _update_extra_fees(self):
+ x_fee = run_hook('get_tx_extra_fee', self.wallet, self.tx)
+ if x_fee:
+ x_fee_address, x_fee_amount = x_fee
+ self.extra_fee_label.setVisible(True)
+ self.extra_fee_value.setVisible(True)
+ self.extra_fee_value.setText(self.main_window.format_amount_and_units(x_fee_amount))
diff --git a/electrum/gui/qt/console.py b/electrum/gui/qt/console.py
new file mode 100644
index 000000000000..e953c3c5bc7e
--- /dev/null
+++ b/electrum/gui/qt/console.py
@@ -0,0 +1,372 @@
+# source: http://stackoverflow.com/questions/2758159/how-to-embed-a-python-interpreter-in-a-pyqt-widget
+
+import sys
+import os
+import re
+import traceback
+
+from PyQt6 import QtCore, QtGui, QtWidgets
+from PyQt6.QtCore import Qt
+
+from electrum import util
+from electrum.i18n import _
+from electrum.base_crash_reporter import taint_reports_by_console_usage
+
+from .util import MONOSPACE_FONT, font_height
+
+# sys.ps1 and sys.ps2 are only declared if an interpreter is in interactive mode.
+sys.ps1 = '>>> '
+sys.ps2 = '... '
+
+
+class OverlayLabel(QtWidgets.QLabel):
+ STYLESHEET = '''
+ QLabel, QLabel link {
+ color: rgb(0, 0, 0);
+ background-color: rgb(248, 240, 200);
+ border: 1px solid;
+ border-color: rgb(255, 114, 47);
+ padding: 2px;
+ }
+ '''
+ def __init__(self, text, parent):
+ super().__init__(text, parent)
+ self.setMinimumHeight(max(150, 10 * font_height()))
+ self.setGeometry(0, 0, self.width(), self.height())
+ self.setStyleSheet(self.STYLESHEET)
+ self.setMargin(0)
+ parent.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ self.setWordWrap(True)
+
+ def mousePressEvent(self, e):
+ self.hide()
+
+ def on_resize(self, w):
+ padding = 2 # px, from the stylesheet above
+ self.setFixedWidth(w - padding)
+
+
+class Console(QtWidgets.QPlainTextEdit):
+ DEFAULT_FONT_SIZE = 10
+ MIN_FONT_SIZE = 6
+ MAX_FONT_SIZE = 32
+
+ def __init__(self, parent=None):
+ QtWidgets.QPlainTextEdit.__init__(self, parent)
+
+ self.history = []
+ self.namespace = {}
+ self.construct = []
+ self.font_size = self.DEFAULT_FONT_SIZE
+
+ self.setGeometry(50, 75, 600, 400)
+ self.setWordWrapMode(QtGui.QTextOption.WrapMode.WrapAnywhere)
+ self.setUndoRedoEnabled(False)
+ self.setFont(QtGui.QFont(MONOSPACE_FONT, self.font_size, QtGui.QFont.Weight.Normal))
+ self.newPrompt("") # make sure there is always a prompt, even before first server.banner
+
+ self.updateNamespace({'run':self.run_script})
+ self.set_json(False)
+
+ warning_text = "
{}
{}
{}".format(
+ _("Warning!"),
+ _("Do not paste code here that you don't understand. Executing the wrong code could lead "
+ "to your coins being irreversibly lost."),
+ _("Click here to hide this message.")
+ )
+ self.messageOverlay = OverlayLabel(warning_text, self)
+
+ def set_font_size(self, size: int):
+ size = max(self.MIN_FONT_SIZE, min(self.MAX_FONT_SIZE, size))
+ self.font_size = size
+ self.setFont(QtGui.QFont(MONOSPACE_FONT, self.font_size, QtGui.QFont.Weight.Normal))
+
+ def resizeEvent(self, e):
+ super().resizeEvent(e)
+ vertical_scrollbar_width = self.verticalScrollBar().width() * self.verticalScrollBar().isVisible()
+ self.messageOverlay.on_resize(self.width() - vertical_scrollbar_width)
+
+ def set_json(self, b):
+ self.is_json = b
+
+ def run_script(self, filename):
+ with open(filename) as f:
+ script = f.read()
+
+ self._exec_command(script)
+
+ def updateNamespace(self, namespace):
+ self.namespace.update(namespace)
+
+ def showMessage(self, message):
+ curr_line = self.getCommand(strip=False)
+ self.appendPlainText(message)
+ self.newPrompt(curr_line)
+
+ def clear(self):
+ curr_line = self.getCommand()
+ self.setPlainText('')
+ self.newPrompt(curr_line)
+
+ def keyboard_interrupt(self):
+ self.construct = []
+ self.appendPlainText('KeyboardInterrupt')
+ self.newPrompt('')
+
+ def newPrompt(self, curr_line):
+ if self.construct:
+ prompt = sys.ps2 + curr_line
+ else:
+ prompt = sys.ps1 + curr_line
+
+ self.completions_pos = self.textCursor().position()
+ self.completions_visible = False
+
+ self.appendPlainText(prompt)
+ self.moveCursor(QtGui.QTextCursor.MoveOperation.End)
+
+ def getCommand(self, *, strip=True):
+ doc = self.document()
+ curr_line = doc.findBlockByLineNumber(doc.lineCount() - 1).text()
+ if strip:
+ curr_line = curr_line.rstrip()
+ curr_line = curr_line[len(sys.ps1):]
+ return curr_line
+
+ def setCommand(self, command):
+ if self.getCommand() == command:
+ return
+
+ doc = self.document()
+ curr_line = doc.findBlockByLineNumber(doc.lineCount() - 1).text()
+ self.moveCursor(QtGui.QTextCursor.MoveOperation.End)
+ for i in range(len(curr_line) - len(sys.ps1)):
+ self.moveCursor(QtGui.QTextCursor.MoveOperation.Left, QtGui.QTextCursor.MoveMode.KeepAnchor)
+
+ self.textCursor().removeSelectedText()
+ self.textCursor().insertText(command)
+ self.moveCursor(QtGui.QTextCursor.MoveOperation.End)
+
+ def show_completions(self, completions):
+ if self.completions_visible:
+ self.hide_completions()
+
+ c = self.textCursor()
+ c.setPosition(self.completions_pos)
+
+ completions = map(lambda x: x.split('.')[-1], completions)
+ t = '\n' + ' '.join(completions)
+ if len(t) > 500:
+ t = t[:500] + '...'
+ c.insertText(t)
+ self.completions_end = c.position()
+
+ self.moveCursor(QtGui.QTextCursor.MoveOperation.End)
+ self.completions_visible = True
+
+ def hide_completions(self):
+ if not self.completions_visible:
+ return
+ c = self.textCursor()
+ c.setPosition(self.completions_pos)
+ l = self.completions_end - self.completions_pos
+ for x in range(l): c.deleteChar()
+
+ self.moveCursor(QtGui.QTextCursor.MoveOperation.End)
+ self.completions_visible = False
+
+ def getConstruct(self, command):
+ if self.construct:
+ self.construct.append(command)
+ if not command:
+ ret_val = '\n'.join(self.construct)
+ self.construct = []
+ return ret_val
+ else:
+ return ''
+ else:
+ if command and command[-1] == (':'):
+ self.construct.append(command)
+ return ''
+ else:
+ return command
+
+ def addToHistory(self, command):
+ if not self.construct and command[0:1] == ' ':
+ return
+
+ if command and (not self.history or self.history[-1] != command):
+ while len(self.history) >= 50:
+ self.history.remove(self.history[0])
+ self.history.append(command)
+ self.history_index = len(self.history)
+
+ def getPrevHistoryEntry(self):
+ if self.history:
+ self.history_index = max(0, self.history_index - 1)
+ return self.history[self.history_index]
+ return ''
+
+ def getNextHistoryEntry(self):
+ if self.history:
+ hist_len = len(self.history)
+ self.history_index = min(hist_len, self.history_index + 1)
+ if self.history_index < hist_len:
+ return self.history[self.history_index]
+ return ''
+
+ def getCursorPosition(self):
+ c = self.textCursor()
+ return c.position() - c.block().position() - len(sys.ps1)
+
+ def setCursorPosition(self, position):
+ self.moveCursor(QtGui.QTextCursor.MoveOperation.StartOfLine)
+ for i in range(len(sys.ps1) + position):
+ self.moveCursor(QtGui.QTextCursor.MoveOperation.Right)
+
+ def run_command(self):
+ command = self.getCommand()
+ self.addToHistory(command)
+
+ command = self.getConstruct(command)
+
+ if command:
+ self._exec_command(command)
+ self.newPrompt('')
+ self.set_json(False)
+
+ def _exec_command(self, command):
+ tmp_stdout = sys.stdout
+ taint_reports_by_console_usage()
+
+ class StdoutProxy:
+ def __init__(self, write_func):
+ self.write_func = write_func
+ self.skip = False
+
+ def flush(self):
+ pass
+
+ def write(self, text):
+ if not self.skip:
+ stripped_text = text.rstrip('\n')
+ self.write_func(stripped_text)
+ QtCore.QCoreApplication.processEvents()
+ self.skip = not self.skip
+
+ 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))
+ return
+
+ sys.stdout = StdoutProxy(self.appendPlainText)
+ try:
+ try:
+ # eval is generally considered bad practice. use it wisely!
+ result = eval(command, self.namespace, self.namespace)
+ if result is not None:
+ if self.is_json:
+ util.print_msg(util.json_encode(result))
+ else:
+ self.appendPlainText(repr(result))
+ except SyntaxError:
+ # exec is generally considered bad practice. use it wisely!
+ exec(command, self.namespace, self.namespace)
+ except SystemExit:
+ self.close()
+ except BaseException as e:
+ te = traceback.TracebackException.from_exception(e)
+ # rm part of traceback mentioning this file.
+ # (note: we rm stack items before converting to str, instead of removing lines from the str,
+ # as this is more reliable. The latter would differ whether the traceback has source text lines,
+ # which is not always the case.)
+ te.stack = traceback.StackSummary.from_list(te.stack[1:])
+ tb_str = "".join(te.format())
+ # rm last linebreak:
+ if tb_str.endswith("\n"):
+ tb_str = tb_str[:-1]
+ self.appendPlainText(tb_str)
+ sys.stdout = tmp_stdout
+
+ def keyPressEvent(self, event):
+ if event.key() == Qt.Key.Key_Tab:
+ self.completions()
+ return
+
+ self.hide_completions()
+
+ if event.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return):
+ self.run_command()
+ return
+ if event.key() == Qt.Key.Key_Home:
+ self.setCursorPosition(0)
+ return
+ if event.key() == Qt.Key.Key_PageUp:
+ return
+ elif event.key() in (Qt.Key.Key_Left, Qt.Key.Key_Backspace):
+ if self.getCursorPosition() == 0:
+ return
+ elif event.key() == Qt.Key.Key_Up:
+ self.setCommand(self.getPrevHistoryEntry())
+ return
+ elif event.key() == Qt.Key.Key_Down:
+ self.setCommand(self.getNextHistoryEntry())
+ return
+ elif event.key() == Qt.Key.Key_L and event.modifiers() == Qt.KeyboardModifier.ControlModifier:
+ self.clear()
+ elif event.key() == Qt.Key.Key_C and event.modifiers() == Qt.KeyboardModifier.ControlModifier:
+ if not self.textCursor().selectedText():
+ self.keyboard_interrupt()
+ elif event.key() == Qt.Key.Key_Plus and Qt.KeyboardModifier.ControlModifier in event.modifiers():
+ self.set_font_size(self.font_size + 1)
+ return
+ elif event.key() == Qt.Key.Key_Minus and Qt.KeyboardModifier.ControlModifier in event.modifiers():
+ self.set_font_size(self.font_size - 1)
+ return
+
+ super(Console, self).keyPressEvent(event)
+
+ def completions(self):
+ cmd = self.getCommand()
+ # note for regex: new words start after ' ' or '(' or ')'
+ lastword = re.split(r'[ ()]', cmd)[-1]
+ beginning = cmd[0:-len(lastword)]
+
+ path = lastword.split('.')
+ prefix = '.'.join(path[:-1])
+ prefix = (prefix + '.') if prefix else prefix
+ ns = self.namespace.keys()
+
+ if len(path) == 1:
+ ns = ns
+ else:
+ assert len(path) > 1
+ obj = self.namespace.get(path[0])
+ try:
+ for attr in path[1:-1]:
+ obj = getattr(obj, attr)
+ except AttributeError:
+ ns = []
+ else:
+ ns = dir(obj)
+
+ completions = []
+ for name in ns:
+ if name[0] == '_':continue
+ if name.startswith(path[-1]):
+ completions.append(prefix+name)
+ completions.sort()
+
+ if not completions:
+ self.hide_completions()
+ elif len(completions) == 1:
+ self.hide_completions()
+ self.setCommand(beginning + completions[0])
+ else:
+ # find common prefix
+ p = os.path.commonprefix(completions)
+ if len(p)>len(lastword):
+ self.hide_completions()
+ self.setCommand(beginning + p)
+ else:
+ self.show_completions(completions)
diff --git a/electrum/gui/qt/contact_list.py b/electrum/gui/qt/contact_list.py
new file mode 100644
index 000000000000..bc9d06bbeaf4
--- /dev/null
+++ b/electrum/gui/qt/contact_list.py
@@ -0,0 +1,150 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2015 Thomas Voegtlin
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import enum
+from typing import TYPE_CHECKING
+
+from PyQt6.QtGui import QStandardItemModel, QStandardItem
+from PyQt6.QtCore import Qt, QPersistentModelIndex, QModelIndex
+from PyQt6.QtWidgets import (QAbstractItemView, QMenu)
+
+from electrum.i18n import _
+from electrum.bitcoin import is_address
+from electrum.util import block_explorer_URL
+from electrum.plugin import run_hook
+from electrum.gui.qt.util import read_QIcon
+
+from .util import webopen
+from .my_treeview import MyTreeView
+
+if TYPE_CHECKING:
+ from .main_window import ElectrumWindow
+
+
+class ContactList(MyTreeView):
+
+ class Columns(MyTreeView.BaseColumnsEnum):
+ NAME = enum.auto()
+ ADDRESS = enum.auto()
+
+ headers = {
+ Columns.NAME: _('Name'),
+ Columns.ADDRESS: _('Address'),
+ }
+ filter_columns = [Columns.NAME, Columns.ADDRESS]
+
+ ROLE_CONTACT_KEY = Qt.ItemDataRole.UserRole + 1000
+ key_role = ROLE_CONTACT_KEY
+
+ def __init__(self, main_window: 'ElectrumWindow'):
+ super().__init__(
+ main_window=main_window,
+ stretch_column=self.Columns.ADDRESS,
+ editable_columns=[self.Columns.NAME],
+ )
+ self.setModel(QStandardItemModel(self))
+ self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
+ self.setSortingEnabled(True)
+ self.std_model = self.model()
+ self.update()
+
+ def on_edited(self, idx, edit_key, *, text):
+ _type, prior_name = self.main_window.contacts.pop(edit_key)
+ self.main_window.set_contact(text, edit_key)
+ self.update()
+
+ def create_menu(self, position):
+ menu = QMenu()
+ idx = self.indexAt(position)
+ column = idx.column() or self.Columns.NAME
+ selected_keys = []
+ for s_idx in self.selected_in_column(self.Columns.NAME):
+ sel_key = self.model().itemFromIndex(s_idx).data(self.ROLE_CONTACT_KEY)
+ selected_keys.append(sel_key)
+ if selected_keys and idx.isValid():
+ column_title = self.model().horizontalHeaderItem(column).text()
+ column_data = '\n'.join(self.model().itemFromIndex(s_idx).text()
+ for s_idx in self.selected_in_column(column))
+ menu.addAction(_("Copy {}").format(column_title), lambda: self.place_text_on_clipboard(column_data, title=column_title))
+ if column in self.editable_columns:
+ item = self.model().itemFromIndex(idx)
+ if item.isEditable():
+ # would not be editable if openalias
+ persistent = QPersistentModelIndex(idx)
+ menu.addAction(_("Edit {}").format(column_title), lambda p=persistent: self.edit(QModelIndex(p)))
+ menu.addAction(_("Pay to"), lambda: self.main_window.payto_contacts(selected_keys))
+ menu.addAction(_("Delete"), lambda: self.main_window.delete_contacts(selected_keys))
+ URLs = [block_explorer_URL(self.config, 'addr', key) for key in filter(is_address, selected_keys)]
+ if URLs:
+ menu.addAction(_("View on block explorer"), lambda: [webopen(u) for u in URLs])
+
+ run_hook('create_contact_menu', menu, selected_keys)
+ self.open_menu(menu, position)
+
+ def update(self):
+ if self.maybe_defer_update():
+ return
+ current_key = self.get_role_data_for_current_item(col=self.Columns.NAME, role=self.ROLE_CONTACT_KEY)
+ self.model().clear()
+ self.update_headers(self.__class__.headers)
+ set_current = None
+ for key in sorted(self.main_window.contacts.keys()):
+ contact_type, name = self.main_window.contacts[key]
+ labels = [""] * len(self.Columns)
+ labels[self.Columns.NAME] = name
+ labels[self.Columns.ADDRESS] = key
+ items = [QStandardItem(x) for x in labels]
+ items[self.Columns.NAME].setEditable(contact_type != 'openalias')
+ items[self.Columns.ADDRESS].setEditable(False)
+ items[self.Columns.NAME].setData(key, self.ROLE_CONTACT_KEY)
+ items[self.Columns.NAME].setIcon(
+ read_QIcon("lightning" if contact_type == 'lnaddress' else "bitcoin")
+ )
+ row_count = self.model().rowCount()
+ self.model().insertRow(row_count, items)
+ if key == current_key:
+ idx = self.model().index(row_count, self.Columns.NAME)
+ set_current = QPersistentModelIndex(idx)
+ self.set_current_idx(set_current)
+ # FIXME refresh loses sort order; so set "default" here:
+ self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder)
+ self.filter()
+ run_hook('update_contacts_tab', self)
+
+ def refresh_row(self, key, row):
+ # nothing to update here
+ pass
+
+ def get_edit_key_from_coordinate(self, row, col):
+ if col != self.Columns.NAME:
+ return None
+ return self.get_role_data_from_coordinate(row, col, role=self.ROLE_CONTACT_KEY)
+
+ def create_toolbar(self, config):
+ toolbar, menu = self.create_toolbar_with_menu('')
+ menu.addAction(_("&New contact"), self.main_window.new_contact_dialog)
+ menu.addAction(_("Import"), lambda: self.main_window.import_contacts())
+ menu.addAction(_("Export"), lambda: self.main_window.export_contacts())
+ return toolbar
diff --git a/electrum/gui/qt/custom_model.py b/electrum/gui/qt/custom_model.py
new file mode 100644
index 000000000000..e9f9b05dc3bb
--- /dev/null
+++ b/electrum/gui/qt/custom_model.py
@@ -0,0 +1,100 @@
+# loosely based on
+# http://trevorius.com/scrapbook/uncategorized/pyqt-custom-abstractitemmodel/
+
+from PyQt6 import QtCore
+
+
+class CustomNode:
+
+ def __init__(self, model: 'CustomModel', 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):
+ # Performance-critical function
+
+ if not _parent or not _parent.isValid():
+ parent = self._root
+ else:
+ parent = _parent.internalPointer()
+
+ # Open-coded
+ # if not QtCore.QAbstractItemModel.hasIndex(self, row, column, _parent):
+ # the implementation is equivalent but it's in C++,
+ # so VM entries take up inordinate amounts of time (up to 25% of refresh()):
+ if row < 0 or column < 0 or row >= self.rowCount(_parent) or column >= self._columncount:
+ return QtCore.QModelIndex()
+
+ child = parent.child(row)
+ if child:
+ return QtCore.QAbstractItemModel.createIndex(self, row, column, child)
+ else:
+ return QtCore.QModelIndex()
+
+ def parent(self, index):
+ if index.isValid():
+ node = index.internalPointer()
+ if node:
+ p = node.parent()
+ if p:
+ return QtCore.QAbstractItemModel.createIndex(self, p.row(), 0, p)
+ else:
+ return QtCore.QModelIndex()
+ return QtCore.QModelIndex()
+
+ def data(self, index, role):
+ if not index.isValid():
+ return None
+ node = index.internalPointer()
+ return node.get_data_for_role(index, role)
diff --git a/electrum/gui/qt/exception_window.py b/electrum/gui/qt/exception_window.py
new file mode 100644
index 000000000000..848b97cc076c
--- /dev/null
+++ b/electrum/gui/qt/exception_window.py
@@ -0,0 +1,216 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+import sys
+import html
+from typing import TYPE_CHECKING, Optional, Set
+
+from PyQt6.QtCore import QObject, Qt
+import PyQt6.QtCore as QtCore
+from PyQt6.QtWidgets import (QWidget, QLabel, QPushButton, QTextEdit,
+ QMessageBox, QHBoxLayout, QVBoxLayout, QDialog, QScrollArea)
+
+from electrum.i18n import _
+from electrum.base_crash_reporter import BaseCrashReporter, EarlyExceptionsQueue, CrashReportResponse
+from electrum.logging import Logger
+from electrum import constants
+from electrum.network import Network
+
+from .util import MessageBoxMixin, read_QIcon, WaitingDialog, font_height
+
+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, config: 'SimpleConfig', exctype, value, tb):
+ BaseCrashReporter.__init__(self, exctype, value, tb)
+ self.network = Network.get_instance()
+ self.config = config
+
+ QWidget.__init__(self)
+ self.setWindowTitle('Electrum - ' + _('An Error Occurred'))
+ self.setMinimumSize(600, 300)
+
+ Logger.__init__(self)
+
+ main_box = QVBoxLayout()
+
+ heading = QLabel('
' + BaseCrashReporter.CRASH_TITLE + '
')
+ main_box.addWidget(heading)
+ main_box.addWidget(QLabel(BaseCrashReporter.CRASH_MESSAGE))
+
+ main_box.addWidget(QLabel(BaseCrashReporter.REQUEST_HELP_MESSAGE))
+
+ self._report_contents_dlg = None # type: Optional[ReportContentsDialog]
+ collapse_info = QPushButton(_("Show report contents"))
+ collapse_info.clicked.connect(lambda _checked: self.show_report_contents_dlg())
+
+ main_box.addWidget(collapse_info)
+
+ main_box.addWidget(QLabel(BaseCrashReporter.DESCRIBE_ERROR_MESSAGE))
+
+ self.description_textfield = QTextEdit()
+ self.description_textfield.setFixedHeight(4 * font_height())
+ self.description_textfield.setPlaceholderText(self.USER_COMMENT_PLACEHOLDER)
+ main_box.addWidget(self.description_textfield)
+
+ main_box.addWidget(QLabel(BaseCrashReporter.ASK_CONFIRM_SEND))
+
+ buttons = QHBoxLayout()
+
+ report_button = QPushButton(_('Send Bug Report'))
+ report_button.clicked.connect(lambda _checked: self._ask_for_confirm_to_send_report())
+ report_button.setIcon(read_QIcon("tab_send.png"))
+ buttons.addWidget(report_button)
+
+ close_button = QPushButton(_('Not Now'))
+ close_button.clicked.connect(lambda _checked: self.close())
+ buttons.addWidget(close_button)
+
+ main_box.addLayout(buttons)
+
+ # prioritizes the window input over all other windows
+ self.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal)
+
+ self.setLayout(main_box)
+ self.show()
+
+ def _ask_for_confirm_to_send_report(self):
+ if self.question("Confirm to send bugreport?"):
+ self.send_report()
+
+ def send_report(self):
+ def on_success(response: CrashReportResponse):
+ text = response.text
+ if response.url:
+ text += f" You can track further progress on GitHub."
+ self.show_message(parent=self,
+ title=_("Crash report"),
+ msg=text,
+ rich_text=True)
+ self.close()
+
+ def on_failure(exc_info):
+ e = exc_info[1]
+ self.logger.error('There was a problem with the automatic reporting', exc_info=exc_info)
+ self.show_critical(parent=self,
+ msg=(_('There was a problem with the automatic reporting:') + ' ' +
+ repr(e)[:120] + '
' +
+ _("Please report this issue manually") +
+ f' on GitHub.'),
+ rich_text=True)
+
+ 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)
+
+ def on_close(self):
+ Exception_Window._active_window = None
+ self.close()
+
+ def closeEvent(self, event):
+ self.on_close()
+ event.accept()
+
+ def get_user_description(self):
+ return self.description_textfield.toPlainText()
+
+ def get_wallet_type(self):
+ wallet_types = Exception_Hook._INSTANCE.wallet_types_seen
+ return ",".join(wallet_types)
+
+ def _get_traceback_str_to_display(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_to_display()
+ return html.escape(traceback_str)
+
+ def show_report_contents_dlg(self):
+ if self._report_contents_dlg is None:
+ self._report_contents_dlg = ReportContentsDialog(
+ parent=self,
+ text=self.get_report_string(),
+ )
+ self._report_contents_dlg.show()
+ self._report_contents_dlg.raise_()
+
+
+def _show_window(*args):
+ if not Exception_Window._active_window:
+ Exception_Window._active_window = Exception_Window(*args)
+
+
+class Exception_Hook(QObject, Logger):
+ _report_exception = QtCore.pyqtSignal(object, object, object, object)
+
+ _INSTANCE = None # type: Optional[Exception_Hook] # singleton
+
+ def __init__(self, *, config: 'SimpleConfig'):
+ QObject.__init__(self)
+ Logger.__init__(self)
+ assert self._INSTANCE is None, "Exception_Hook is supposed to be a singleton"
+ self.config = config
+ self.wallet_types_seen = set() # type: Set[str]
+ self.exception_ids_seen = set() # type: Set[bytes]
+
+ sys.excepthook = self.handler
+ self._report_exception.connect(_show_window)
+ EarlyExceptionsQueue.set_hook_as_ready()
+
+ @classmethod
+ def maybe_setup(cls, *, config: 'SimpleConfig', wallet: 'Abstract_Wallet' = None) -> None:
+ if not cls._INSTANCE:
+ cls._INSTANCE = Exception_Hook(config=config)
+ if wallet:
+ 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)
+ groupid_hash = BaseCrashReporter.get_traceback_groupid_hash(*exc_info)
+ if groupid_hash in self.exception_ids_seen:
+ return # to avoid annoying the user, only show crash reporter once per exception groupid
+ self.exception_ids_seen.add(groupid_hash)
+ self._report_exception.emit(self.config, *exc_info)
+
+
+class ReportContentsDialog(QDialog):
+
+ def __init__(self, *, parent: QWidget, text: str):
+ QDialog.__init__(self, parent)
+ self.setWindowTitle(_("Report contents"))
+ self.setMinimumSize(800, 500)
+ vbox = QVBoxLayout(self)
+ scroll_area = QScrollArea(self)
+
+ report_text = QLabel(text)
+ report_text.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
+ report_text.setTextFormat(Qt.TextFormat.AutoText) # likely rich text
+
+ scroll_area.setWidget(report_text)
+ vbox.addWidget(scroll_area)
diff --git a/electrum/gui/qt/fee_slider.py b/electrum/gui/qt/fee_slider.py
new file mode 100644
index 000000000000..79a76439fca4
--- /dev/null
+++ b/electrum/gui/qt/fee_slider.py
@@ -0,0 +1,111 @@
+import threading
+from typing import Callable, Optional
+
+from PyQt6.QtGui import QCursor
+from PyQt6.QtCore import Qt
+from PyQt6.QtWidgets import QSlider, QToolTip, QComboBox, QWidget
+
+from electrum.i18n import _
+from electrum.fee_policy import FeeMethod, FeePolicy
+from electrum.network import Network
+
+
+class FeeComboBox(QComboBox):
+
+ def __init__(self, fee_slider: 'FeeSlider'):
+ QComboBox.__init__(self)
+ self.fee_slider = fee_slider
+ self.addItems([x.name_for_GUI() for x in FeeMethod.slider_values()])
+ index = FeeMethod.slider_index_of_method(self.fee_slider.fee_policy.method)
+ self.setCurrentIndex(index)
+ self.currentIndexChanged.connect(self.on_fee_type)
+ self.help_msg = '\n'.join([
+ _('Feerate: the fee slider uses static feerate 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):
+ method = FeeMethod.slider_values()[x]
+ self.fee_slider.fee_policy.set_method(method)
+ self.fee_slider.update(is_initialized=True)
+
+
+class FeeSlider(QSlider):
+
+ def __init__(
+ self,
+ *,
+ parent: Optional[QWidget],
+ network: Network,
+ fee_policy: FeePolicy,
+ callback: Callable[[Optional[int]], None],
+ ):
+ QSlider.__init__(self, Qt.Orientation.Horizontal, parent=parent)
+ self.network = network
+ self.callback = callback
+ self.fee_policy = fee_policy
+ self.lock = threading.RLock()
+ self.update(is_initialized=False)
+ self.valueChanged.connect(self.moved)
+ self._active = True
+
+ @property
+ def dyn(self) -> bool:
+ return self.fee_policy.use_dynamic_estimates
+
+ def get_policy(self) -> FeePolicy:
+ return self.fee_policy
+
+ def moved(self, pos):
+ with self.lock:
+ if self.fee_policy.method == FeeMethod.FIXED:
+ return
+ self.fee_policy.set_value_from_slider_pos(pos)
+ fee_rate = self.fee_policy.fee_per_kb(self.network)
+ tooltip = self.fee_policy.get_tooltip(self.network)
+ QToolTip.showText(QCursor.pos(), tooltip, self)
+ self.setToolTip(tooltip)
+ self.callback(fee_rate)
+
+ def update(self, *, is_initialized: bool = True):
+ with self.lock:
+ if self.fee_policy.method == FeeMethod.FIXED:
+ return
+ pos = self.fee_policy.get_slider_pos()
+ maxp = self.fee_policy.get_slider_max()
+ self.setRange(0, maxp)
+ self.setValue(pos)
+ if is_initialized:
+ self.moved(pos)
+
+ def activate(self):
+ self._active = True
+ self.setStyleSheet('')
+
+ def deactivate(self):
+ self._active = False
+ # TODO it would be nice to find a platform-independent solution
+ # that makes the slider look as if it was disabled
+ self.setStyleSheet(
+ """
+ QSlider::groove:horizontal {
+ border: 1px solid #999999;
+ height: 8px;
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #B1B1B1, stop:1 #B1B1B1);
+ margin: 2px 0;
+ }
+
+ QSlider::handle:horizontal {
+ background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #b4b4b4, stop:1 #8f8f8f);
+ border: 1px solid #5c5c5c;
+ width: 12px;
+ margin: -2px 0;
+ border-radius: 3px;
+ }
+ """
+ )
+
+ def is_active(self):
+ return self._active
diff --git a/electrum/gui/qt/history_list.py b/electrum/gui/qt/history_list.py
new file mode 100644
index 000000000000..c1d44fff8a05
--- /dev/null
+++ b/electrum/gui/qt/history_list.py
@@ -0,0 +1,866 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2015 Thomas Voegtlin
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import os
+import time
+import datetime
+from datetime import date
+from typing import TYPE_CHECKING, Tuple, Dict, Any
+import threading
+import enum
+from decimal import Decimal
+
+from PyQt6.QtGui import QFont, QBrush, QColor
+from PyQt6.QtCore import (Qt, QPersistentModelIndex, QModelIndex,
+ QSortFilterProxyModel, QVariant, QItemSelectionModel, QDate, QPoint)
+from PyQt6.QtWidgets import (QMenu, QHeaderView, QLabel, QPushButton, QComboBox, QVBoxLayout, QCalendarWidget,
+ QGridLayout)
+
+from electrum.gui import messages
+from electrum.address_synchronizer import TX_HEIGHT_LOCAL
+from electrum.i18n import _
+from electrum.util import (block_explorer_URL, profiler, TxMinedInfo,
+ OrderedDictWithIndex, timestamp_to_datetime,
+ Satoshis, format_time)
+from electrum.logging import get_logger, Logger
+from electrum.simple_config import SimpleConfig
+
+from .custom_model import CustomNode, CustomModel
+from .util import (read_QIcon, MONOSPACE_FONT, Buttons, CancelButton, OkButton,
+ filename_field, AcceptFileDragDrop, WindowModalDialog,
+ CloseButton, webopen, WWLabel)
+from .my_treeview import MyTreeView
+
+if TYPE_CHECKING:
+ from electrum.wallet import Abstract_Wallet
+ from .main_window import ElectrumWindow
+
+
+_logger = get_logger(__name__)
+
+
+TX_ICONS = [
+ "unconfirmed.png",
+ "warning.png",
+ "offline_tx.png",
+ "offline_tx.png",
+ "clock1.png",
+ "clock2.png",
+ "clock3.png",
+ "clock4.png",
+ "clock5.png",
+ "confirmed.png",
+]
+
+
+class HistorySortModel(QSortFilterProxyModel):
+
+ def data_for(self, index: QModelIndex):
+ col = index.column()
+ if col == HistoryColumns.STATUS:
+ # respect sort order of self.transactions (wallet.get_full_history)
+ return index.row()
+ else:
+ node = index.internalPointer()
+ return node.sort_keys[col]
+
+ def lessThan(self, source_left: QModelIndex, source_right: QModelIndex):
+ return self.data_for(source_left) < self.data_for(source_right)
+
+
+def get_item_key(tx_item):
+ return tx_item.get('txid') or tx_item['payment_hash']
+
+def flatten_sort_key(v):
+ if v is None or isinstance(v, Decimal) and v.is_nan():
+ return -float("inf")
+ else:
+ return v
+
+
+class HistoryNode(CustomNode):
+
+ model: 'HistoryModel'
+
+ def __init__(self, model: 'CustomModel', tx_item):
+ super().__init__(model, tx_item)
+
+ if tx_item is None:
+ tx_item = {}
+ is_lightning = tx_item.get('lightning', False)
+ short_id = ""
+ if not is_lightning:
+ txpos_in_block = tx_item.get('txpos_in_block') or -1
+ if txpos_in_block >= 0:
+ short_id = f"{tx_item['height']}x{txpos_in_block}"
+ self.sort_keys = {
+ HistoryColumns.DESCRIPTION: flatten_sort_key(
+ tx_item.get('label')),
+ HistoryColumns.AMOUNT: flatten_sort_key(
+ (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: 0,
+ HistoryColumns.FIAT_VALUE: flatten_sort_key(
+ tx_item['fiat_value'].value if 'fiat_value' in tx_item else None),
+ HistoryColumns.FIAT_ACQ_PRICE: flatten_sort_key(
+ tx_item['acquisition_price'].value if 'acquisition_price' in tx_item else None),
+ HistoryColumns.FIAT_CAP_GAINS: flatten_sort_key(
+ tx_item['capital_gain'].value if 'capital_gain' in tx_item else None),
+ HistoryColumns.TXID: flatten_sort_key(
+ tx_item.get('txid') if not is_lightning else None),
+ HistoryColumns.SHORT_ID:
+ short_id,
+ }
+
+ def set_balance(self, balance):
+ self._data['balance'] = Satoshis(balance)
+ self.sort_keys[HistoryColumns.BALANCE] = balance
+
+ def get_data_for_role(self, index: QModelIndex, role: Qt.ItemDataRole) -> QVariant:
+ assert index.isValid()
+ col = index.column()
+ window = self.model.window
+ tx_item = self.get_data()
+ is_lightning = tx_item.get('lightning', False)
+ if not is_lightning and 'txid' not in tx_item:
+ # this may happen if two lightning tx have the same group id
+ # and the group does not have an onchain tx
+ is_lightning = True
+ timestamp = tx_item['timestamp']
+ if is_lightning:
+ status = 0
+ if timestamp is None:
+ status_str = 'unconfirmed'
+ else:
+ status_str = format_time(int(timestamp))
+ else:
+ tx_hash = tx_item['txid']
+ conf = tx_item['confirmations']
+ try:
+ status, status_str = self.model.tx_status_cache[tx_hash]
+ except KeyError:
+ 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 == MyTreeView.ROLE_EDIT_KEY:
+ return QVariant(get_item_key(tx_item))
+ if role not in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole, MyTreeView.ROLE_CLIPBOARD_DATA):
+ if col == HistoryColumns.STATUS and role == Qt.ItemDataRole.DecorationRole:
+ icon = "lightning" if is_lightning else TX_ICONS[status]
+ return QVariant(read_QIcon(icon))
+ elif col == HistoryColumns.STATUS and role == Qt.ItemDataRole.ToolTipRole:
+ 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.ItemDataRole.TextAlignmentRole:
+ return QVariant(int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter))
+ elif col > HistoryColumns.DESCRIPTION and role == Qt.ItemDataRole.FontRole:
+ monospace_font = QFont(MONOSPACE_FONT)
+ return QVariant(monospace_font)
+ #elif col == HistoryColumns.DESCRIPTION and role == Qt.ItemDataRole.DecorationRole and not is_lightning\
+ # and self.parent.wallet.invoices.paid.get(tx_hash):
+ # return QVariant(read_QIcon("seal"))
+ elif col in (HistoryColumns.DESCRIPTION, HistoryColumns.AMOUNT) \
+ and role == Qt.ItemDataRole.ForegroundRole and tx_item['value'].value < 0:
+ red_brush = QBrush(QColor("#BC1E1E"))
+ return QVariant(red_brush)
+ elif col == HistoryColumns.FIAT_VALUE and role == Qt.ItemDataRole.ForegroundRole \
+ and not tx_item.get('fiat_default') and tx_item.get('fiat_value') is not None:
+ blue_brush = QBrush(QColor("#1E1EFF"))
+ return QVariant(blue_brush)
+ return QVariant()
+
+ add_thousands_sep = None
+ whitespaces = True
+ if role == MyTreeView.ROLE_CLIPBOARD_DATA:
+ add_thousands_sep = False
+ whitespaces = False
+
+ if col == HistoryColumns.STATUS:
+ return QVariant(status_str)
+ elif col == HistoryColumns.DESCRIPTION and 'label' in tx_item:
+ return QVariant(tx_item['label'])
+ elif col == HistoryColumns.AMOUNT:
+ 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 = window.format_amount(value, is_diff=True, whitespaces=whitespaces, add_thousands_sep=add_thousands_sep)
+ return QVariant(v_str)
+ elif col == HistoryColumns.BALANCE:
+ balance = tx_item['balance'].value if 'balance' in tx_item else None
+ balance_str = window.format_amount(balance, whitespaces=whitespaces, add_thousands_sep=add_thousands_sep) if balance is not None else ''
+ return QVariant(balance_str)
+ elif col == HistoryColumns.FIAT_VALUE and 'fiat_value' in tx_item:
+ value_str = window.fx.format_fiat(tx_item['fiat_value'].value, add_thousands_sep=add_thousands_sep)
+ 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(window.fx.format_fiat(acq, add_thousands_sep=add_thousands_sep))
+ elif col == HistoryColumns.FIAT_CAP_GAINS and 'capital_gain' in tx_item:
+ cg = tx_item['capital_gain'].value
+ return QVariant(window.fx.format_fiat(cg, add_thousands_sep=add_thousands_sep))
+ elif col == HistoryColumns.TXID:
+ return QVariant(tx_hash) if not is_lightning else QVariant('')
+ elif col == HistoryColumns.SHORT_ID:
+ return QVariant(self.sort_keys[HistoryColumns.SHORT_ID])
+ return QVariant()
+
+
+class HistoryModel(CustomModel, Logger):
+
+ def __init__(self, window: 'ElectrumWindow'):
+ CustomModel.__init__(self, window, len(HistoryColumns))
+ Logger.__init__(self)
+ self.window = window
+ 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 update_label(self, index):
+ tx_item = index.internalPointer().get_data()
+ tx_item['label'] = self.window.wallet.get_label_for_txid(
+ get_item_key(tx_item)) # FIXME get_item_key might return an RHASH, but we call get_label_for_txid?!
+ topLeft = bottomRight = self.createIndex(index.row(), HistoryColumns.DESCRIPTION)
+ self.dataChanged.emit(topLeft, bottomRight, [Qt.ItemDataRole.DisplayRole])
+ self.window.utxo_list.update()
+
+ def get_domain(self):
+ """Overridden in address_dialog.py"""
+ return None
+
+ def should_include_lightning_payments(self) -> bool:
+ """Overridden in address_dialog.py"""
+ return True
+
+ def should_show_fiat(self):
+ if not self.window.config.FX_HISTORY_RATES:
+ return False
+ fx = self.window.fx
+ if not fx or not fx.is_enabled():
+ return False
+ return fx.has_history()
+
+ def should_show_capital_gains(self):
+ return self.should_show_fiat() and self.window.config.FX_HISTORY_RATES_CAPITAL_GAINS
+
+ @profiler
+ def refresh(self, reason: str):
+ self.logger.info(f"refreshing... reason: {reason}")
+ assert self.window.gui_thread == threading.current_thread(), 'must be called from GUI thread'
+ assert self.view, 'view not set'
+ if self.view.maybe_defer_update():
+ return
+ selected = self.view.selectionModel().currentIndex()
+ selected_row = None
+ if selected:
+ selected_row = selected.row()
+ fx = self.window.fx
+ if fx:
+ fx.history_used_spot = False
+ wallet = self.window.wallet
+ self.set_visibility_of_columns()
+ transactions = wallet.get_full_history(
+ fx=self.window.fx if self.should_show_fiat() else None,
+ onchain_domain=self.get_domain(),
+ include_lightning=self.should_include_lightning_payments(),
+ )
+ 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()
+ parents = {}
+ for tx_item in transactions.values():
+ node = HistoryNode(self, tx_item)
+ self._root.addChild(node)
+ for child_item in tx_item.get('children', []):
+ child_node = HistoryNode(self, child_item)
+ # add child to parent
+ node.addChild(child_node)
+
+ # compute balance once all children have been added
+ balance = 0
+ for node in self._root._children:
+ balance += node._data['value'].value
+ node.set_balance(balance)
+
+ # update tx_status_cache (before endInsertRows() triggers get_data_for_role() calls)
+ self.tx_status_cache.clear()
+ for txid, tx_item in transactions.items():
+ if not tx_item.get('lightning', False):
+ tx_mined_info = self._tx_mined_info_from_tx_item(tx_item)
+ self.tx_status_cache[txid] = self.window.wallet.get_tx_status(txid, tx_mined_info)
+
+ 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.SelectionFlag.Rows | QItemSelectionModel.SelectionFlag.SelectCurrent)
+ self.view.filter()
+ # update time filter
+ if not self.view.years and self.transactions:
+ start_date = date.today()
+ end_date = date.today()
+ if len(self.transactions) > 0:
+ start_date = self.transactions.value_from_pos(0).get('date') or start_date
+ end_date = self.transactions.value_from_pos(len(self.transactions) - 1).get('date') or end_date
+ self.view.years = [str(i) for i in range(start_date.year, end_date.year + 1)]
+ self.view.period_combo.insertItems(1, self.view.years)
+ # update counter
+ num_tx = len(self.transactions)
+ if self.view:
+ self.view.num_tx_label.setText(_("{} transactions").format(num_tx))
+
+ def set_visibility_of_columns(self):
+ def set_visible(col: int, b: bool):
+ self.view.showColumn(col) if b else self.view.hideColumn(col)
+
+ # txid
+ set_visible(HistoryColumns.TXID, False)
+ set_visible(HistoryColumns.SHORT_ID, False)
+ # fiat
+ history = self.should_show_fiat()
+ cap_gains = self.should_show_capital_gains()
+ set_visible(HistoryColumns.FIAT_VALUE, history)
+ set_visible(HistoryColumns.FIAT_ACQ_PRICE, history and cap_gains)
+ set_visible(HistoryColumns.FIAT_CAP_GAINS, history and cap_gains)
+
+ def update_fiat(self, idx):
+ tx_item = idx.internalPointer().get_data()
+ txid = tx_item['txid']
+ fee = tx_item.get('fee')
+ value = tx_item['value'].value
+ fiat_fields = self.window.wallet.get_tx_item_fiat(
+ tx_hash=txid, amount_sat=value, fx=self.window.fx, tx_fee=fee.value if fee else None)
+ tx_item.update(fiat_fields)
+ self.dataChanged.emit(idx, idx, [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.ForegroundRole])
+
+ def update_tx_mined_status(self, tx_hash: str, tx_mined_info: TxMinedInfo):
+ try:
+ row = self.transactions.pos_from_key(tx_hash)
+ tx_item = self.transactions[tx_hash]
+ except KeyError:
+ return
+ self.tx_status_cache[tx_hash] = self.window.wallet.get_tx_status(tx_hash, tx_mined_info)
+ tx_item.update({
+ 'confirmations': tx_mined_info.conf,
+ 'timestamp': tx_mined_info.timestamp,
+ 'txpos_in_block': tx_mined_info.txpos,
+ 'date': timestamp_to_datetime(tx_mined_info.timestamp),
+ })
+ topLeft = self.createIndex(row, 0)
+ bottomRight = self.createIndex(row, len(HistoryColumns) - 1)
+ self.dataChanged.emit(topLeft, bottomRight)
+
+ def on_fee_histogram(self):
+ for tx_hash, tx_item in list(self.transactions.items()):
+ if tx_item.get('lightning'):
+ continue
+ tx_mined_info = self._tx_mined_info_from_tx_item(tx_item)
+ if tx_mined_info.conf > 0:
+ # note: we could actually break here if we wanted to rely on the order of txns in self.transactions
+ continue
+ self.update_tx_mined_status(tx_hash, tx_mined_info)
+
+ def headerData(self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole.DisplayRole):
+ assert orientation == Qt.Orientation.Horizontal
+ if role != Qt.ItemDataRole.DisplayRole:
+ return None
+ fx = self.window.fx
+ fiat_title = 'n/a fiat value'
+ fiat_acq_title = 'n/a fiat acquisition price'
+ fiat_cg_title = 'n/a fiat capital gains'
+ if self.should_show_fiat():
+ fiat_title = '%s ' % fx.ccy + _('Value')
+ fiat_acq_title = '%s ' % fx.ccy + _('Acquisition price')
+ fiat_cg_title = '%s ' % fx.ccy + _('Capital Gains')
+ return {
+ HistoryColumns.STATUS: _('Date'),
+ HistoryColumns.DESCRIPTION: _('Description'),
+ HistoryColumns.AMOUNT: _('Amount'),
+ HistoryColumns.BALANCE: _('Balance'),
+ HistoryColumns.FIAT_VALUE: fiat_title,
+ HistoryColumns.FIAT_ACQ_PRICE: fiat_acq_title,
+ HistoryColumns.FIAT_CAP_GAINS: fiat_cg_title,
+ HistoryColumns.TXID: 'TXID',
+ HistoryColumns.SHORT_ID: 'Short ID',
+ }[section]
+
+ def flags(self, idx: QModelIndex) -> Qt.ItemFlag:
+ extra_flags = Qt.ItemFlag.NoItemFlags # type: Qt.ItemFlag
+ if idx.column() in self.view.editable_columns:
+ extra_flags |= Qt.ItemFlag.ItemIsEditable
+ return super().flags(idx) | extra_flags
+
+ @staticmethod
+ def _tx_mined_info_from_tx_item(tx_item: Dict[str, Any]) -> TxMinedInfo:
+ # FIXME a bit hackish to have to reconstruct the TxMinedInfo... same thing in qml-gui
+ tx_mined_info = TxMinedInfo(
+ _height=tx_item['height'],
+ conf=tx_item['confirmations'],
+ timestamp=tx_item['timestamp'],
+ wanted_height=tx_item.get('wanted_height', None),
+ )
+ return tx_mined_info
+
+
+class HistoryList(MyTreeView, AcceptFileDragDrop):
+
+ class Columns(MyTreeView.BaseColumnsEnum):
+ STATUS = enum.auto()
+ DESCRIPTION = enum.auto()
+ AMOUNT = enum.auto()
+ BALANCE = enum.auto()
+ FIAT_VALUE = enum.auto()
+ FIAT_ACQ_PRICE = enum.auto()
+ FIAT_CAP_GAINS = enum.auto()
+ TXID = enum.auto()
+ SHORT_ID = enum.auto() # ~SCID
+
+ filter_columns = [
+ Columns.STATUS,
+ Columns.DESCRIPTION,
+ Columns.AMOUNT,
+ Columns.TXID,
+ Columns.SHORT_ID,
+ ]
+
+ def tx_item_from_proxy_row(self, proxy_row):
+ hm_idx = self.model().mapToSource(self.model().index(proxy_row, 0))
+ return hm_idx.internalPointer().get_data()
+
+ def should_hide(self, proxy_row):
+ if self.start_date and self.end_date:
+ tx_item = self.tx_item_from_proxy_row(proxy_row)
+ date = tx_item['date']
+ if date:
+ in_interval = self.start_date <= date <= self.end_date
+ if not in_interval:
+ return True
+ return False
+
+ def __init__(self, main_window: 'ElectrumWindow', model: HistoryModel):
+ super().__init__(
+ main_window=main_window,
+ stretch_column=HistoryColumns.DESCRIPTION,
+ editable_columns=[HistoryColumns.DESCRIPTION, HistoryColumns.FIAT_VALUE],
+ )
+ self.hm = model
+ self.proxy = HistorySortModel(self)
+ self.proxy.setSourceModel(model)
+ self.setModel(self.proxy)
+ AcceptFileDragDrop.__init__(self, ".txn")
+ self.setSortingEnabled(True)
+ self.start_date = None
+ self.end_date = None
+ self.years = []
+ self.period_combo = QComboBox()
+ self.start_button = QPushButton('-')
+ self.start_button.pressed.connect(self.select_start_date)
+ self.start_button.setEnabled(False)
+ self.end_button = QPushButton('-')
+ self.end_button.pressed.connect(self.select_end_date)
+ self.end_button.setEnabled(False)
+ self.period_combo.addItems([_('All'), _('Custom')])
+ self.period_combo.activated.connect(self.on_combo)
+ self.wallet = self.main_window.wallet # type: Abstract_Wallet
+ self.sortByColumn(HistoryColumns.STATUS, Qt.SortOrder.DescendingOrder)
+ self.setRootIsDecorated(True)
+ self.header().setStretchLastSection(False)
+ for col in HistoryColumns:
+ sm = QHeaderView.ResizeMode.Stretch if col == self.stretch_column else QHeaderView.ResizeMode.ResizeToContents
+ self.header().setSectionResizeMode(col, sm)
+ if self.config:
+ self.configvar_show_toolbar = self.config.cv.GUI_QT_HISTORY_TAB_SHOW_TOOLBAR
+
+ def update(self):
+ self.hm.refresh('HistoryList.update()')
+
+ def format_date(self, d):
+ return str(datetime.date(d.year, d.month, d.day)) if d else _('None')
+
+ def on_combo(self, x):
+ s = self.period_combo.itemText(x)
+ x = s == _('Custom')
+ self.start_button.setEnabled(x)
+ self.end_button.setEnabled(x)
+ if s == _('All'):
+ self.start_date = None
+ self.end_date = None
+ self.start_button.setText("-")
+ self.end_button.setText("-")
+ else:
+ try:
+ year = int(s)
+ except Exception:
+ return
+ self.start_date = datetime.datetime(year, 1, 1)
+ self.end_date = datetime.datetime(year+1, 1, 1)
+ self.start_button.setText(_('From') + ' ' + self.format_date(self.start_date))
+ self.end_button.setText(_('To') + ' ' + self.format_date(self.end_date))
+ self.hide_rows()
+
+ def create_toolbar(self, config: 'SimpleConfig'):
+ toolbar, menu = self.create_toolbar_with_menu('')
+ self.num_tx_label = toolbar.itemAt(0).widget()
+ self._toolbar_checkbox = menu.addToggle(_("Filter by Date"), lambda: self.toggle_toolbar())
+ self.menu_fiat = menu.addConfig(config.cv.FX_HISTORY_RATES, short_desc=_('Show Fiat Values'), callback=self.main_window.app.update_fiat_signal.emit)
+ self.menu_capgains = menu.addConfig(config.cv.FX_HISTORY_RATES_CAPITAL_GAINS, callback=self.main_window.app.update_fiat_signal.emit)
+ self.menu_summary = menu.addAction(_("&Summary"), self.show_summary)
+ menu.addAction(_("&Plot"), self.plot_history_dialog)
+ menu.addAction(_("&Export"), self.export_history_dialog)
+ hbox = self.create_toolbar_buttons()
+ toolbar.insertLayout(1, hbox)
+ self.update_toolbar_menu()
+ return toolbar
+
+ def update_toolbar_menu(self):
+ fx = self.main_window.fx
+ self.menu_fiat.setEnabled(fx and fx.can_have_history())
+ # setChecked because has_history can be modified through settings dialog
+ self.menu_fiat.setChecked(fx and fx.has_history())
+ self.menu_capgains.setEnabled(fx and fx.has_history())
+ self.menu_summary.setEnabled(fx and fx.has_history())
+
+ def get_toolbar_buttons(self):
+ return self.period_combo, self.start_button, self.end_button
+
+ def on_hide_toolbar(self):
+ self.start_date = None
+ self.end_date = None
+ self.hide_rows()
+
+ def select_start_date(self):
+ self.start_date = self.select_date(self.start_button)
+ self.hide_rows()
+
+ def select_end_date(self):
+ self.end_date = self.select_date(self.end_button)
+ self.hide_rows()
+
+ def select_date(self, button):
+ d = WindowModalDialog(self, _("Select date"))
+ d.setMinimumSize(600, 150)
+ d.date = None
+ vbox = QVBoxLayout()
+
+ def on_date(date):
+ d.date = date
+
+ cal = QCalendarWidget()
+ cal.setGridVisible(True)
+ cal.clicked[QDate].connect(on_date)
+ vbox.addWidget(cal)
+ vbox.addLayout(Buttons(OkButton(d), CancelButton(d)))
+ d.setLayout(vbox)
+ if d.exec():
+ if d.date is None:
+ return None
+ date = d.date.toPyDate()
+ button.setText(self.format_date(date))
+ return datetime.datetime(date.year, date.month, date.day)
+
+ def show_summary(self):
+ if not self.hm.should_show_fiat():
+ self.main_window.show_message(_("Enable fiat exchange rate with history."))
+ return
+ fx = self.main_window.fx
+ summary = self.wallet.get_onchain_capital_gains(
+ from_timestamp=time.mktime(self.start_date.timetuple()) if self.start_date else None,
+ to_timestamp=time.mktime(self.end_date.timetuple()) if self.end_date else None,
+ fx=fx)
+ if not summary:
+ self.main_window.show_message(_("Nothing to summarize."))
+ return
+ start = summary['begin']
+ end = summary['end']
+ flow = summary['flow']
+ start_date = start.get('date')
+ end_date = end.get('date')
+ format_amount = lambda x: self.main_window.format_amount(x.value) + ' ' + self.main_window.base_unit()
+ format_fiat = lambda x: str(x) + ' ' + self.main_window.fx.ccy
+
+ d = WindowModalDialog(self, _("Summary"))
+ d.setMinimumSize(600, 150)
+ vbox = QVBoxLayout()
+ msg = messages.to_rtf(messages.MSG_CAPITAL_GAINS)
+ vbox.addWidget(WWLabel(msg))
+ grid = QGridLayout()
+ grid.addWidget(QLabel(_("Begin")), 0, 1)
+ grid.addWidget(QLabel(_("End")), 0, 2)
+ #
+ grid.addWidget(QLabel(_("Date")), 1, 0)
+ grid.addWidget(QLabel(self.format_date(start_date)), 1, 1)
+ grid.addWidget(QLabel(self.format_date(end_date)), 1, 2)
+ #
+ grid.addWidget(QLabel(_("BTC balance")), 2, 0)
+ grid.addWidget(QLabel(format_amount(start['BTC_balance'])), 2, 1)
+ grid.addWidget(QLabel(format_amount(end['BTC_balance'])), 2, 2)
+ #
+ grid.addWidget(QLabel(_("BTC Fiat price")), 3, 0)
+ grid.addWidget(QLabel(format_fiat(start.get('BTC_fiat_price'))), 3, 1)
+ grid.addWidget(QLabel(format_fiat(end.get('BTC_fiat_price'))), 3, 2)
+ #
+ grid.addWidget(QLabel(_("Fiat balance")), 4, 0)
+ grid.addWidget(QLabel(format_fiat(start.get('fiat_balance'))), 4, 1)
+ grid.addWidget(QLabel(format_fiat(end.get('fiat_balance'))), 4, 2)
+ #
+ grid.addWidget(QLabel(_("Acquisition price")), 5, 0)
+ grid.addWidget(QLabel(format_fiat(start.get('acquisition_price', ''))), 5, 1)
+ grid.addWidget(QLabel(format_fiat(end.get('acquisition_price', ''))), 5, 2)
+ #
+ grid.addWidget(QLabel(_("Unrealized capital gains")), 6, 0)
+ grid.addWidget(QLabel(format_fiat(start.get('unrealized_gains', ''))), 6, 1)
+ grid.addWidget(QLabel(format_fiat(end.get('unrealized_gains', ''))), 6, 2)
+ #
+ grid2 = QGridLayout()
+ grid2.addWidget(QLabel(_("BTC incoming")), 0, 0)
+ grid2.addWidget(QLabel(format_amount(flow['BTC_incoming'])), 0, 1)
+ grid2.addWidget(QLabel(_("Fiat incoming")), 1, 0)
+ grid2.addWidget(QLabel(format_fiat(flow.get('fiat_incoming'))), 1, 1)
+ grid2.addWidget(QLabel(_("BTC outgoing")), 2, 0)
+ grid2.addWidget(QLabel(format_amount(flow['BTC_outgoing'])), 2, 1)
+ grid2.addWidget(QLabel(_("Fiat outgoing")), 3, 0)
+ grid2.addWidget(QLabel(format_fiat(flow.get('fiat_outgoing'))), 3, 1)
+ #
+ grid2.addWidget(QLabel(_("Realized capital gains")), 4, 0)
+ grid2.addWidget(QLabel(format_fiat(flow.get('realized_capital_gains'))), 4, 1)
+ vbox.addLayout(grid)
+ vbox.addWidget(QLabel(_('Cash flow')))
+ vbox.addLayout(grid2)
+ vbox.addLayout(Buttons(CloseButton(d)))
+ d.setLayout(vbox)
+ d.exec()
+
+ def plot_history_dialog(self):
+ try:
+ from electrum.plot import plot_history, NothingToPlotException
+ except ImportError as e:
+ _logger.error(f"could not import electrum.plot. This feature needs matplotlib to be installed. exc={e!r}")
+ self.main_window.show_message("\n\n".join([
+ _("This feature requires the 'matplotlib' Python library which is not "
+ "included in Electrum by default."),
+ _("If you run Electrum from source you can install matplotlib to use this feature."),
+ _("It is not possible to install matplotlib inside the binary executables "
+ "(e.g. AppImage or Windows installation).")
+ ]))
+ return
+ try:
+ plt = plot_history(list(self.hm.transactions.values()))
+ plt.show()
+ except NothingToPlotException as e:
+ self.main_window.show_message(str(e))
+
+ def on_edited(self, idx, edit_key, *, text):
+ index = self.model().mapToSource(idx)
+ 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(index)
+ self.main_window.update_completions()
+ elif column == HistoryColumns.FIAT_VALUE:
+ self.wallet.set_fiat_value(key, self.main_window.fx.ccy, text, self.main_window.fx, tx_item['value'].value)
+ value = tx_item['value'].value
+ if value is not None:
+ self.hm.update_fiat(index)
+ else:
+ raise Exception(f"did not expect {column=!r} to get edited")
+
+ def on_double_click(self, idx):
+ tx_item = idx.internalPointer().get_data()
+ if tx_item.get('lightning'):
+ if tx_item['type'] == 'payment':
+ self.main_window.show_lightning_transaction(tx_item)
+ return
+ tx_hash = tx_item['txid']
+ tx = self.wallet.adb.get_transaction(tx_hash)
+ if not tx:
+ return
+ self.main_window.show_transaction(tx)
+
+ def add_copy_menu(self, menu, idx):
+ cc = menu.addMenu(_("Copy"))
+ for column in HistoryColumns:
+ if self.isColumnHidden(column):
+ continue
+ column_title = self.hm.headerData(column, Qt.Orientation.Horizontal, Qt.ItemDataRole.DisplayRole)
+ idx2 = idx.sibling(idx.row(), column)
+ clipboard_data = self.hm.data(idx2, self.ROLE_CLIPBOARD_DATA).value()
+ if clipboard_data is None:
+ clipboard_data = (self.hm.data(idx2, Qt.ItemDataRole.DisplayRole).value() or '').strip()
+ cc.addAction(
+ column_title,
+ lambda text=clipboard_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)
+ idx = self.proxy.mapToSource(org_idx)
+ if not idx.isValid():
+ # can happen e.g. before list is populated for the first time
+ return
+ tx_item = idx.internalPointer().get_data()
+ if tx_item.get('lightning'):
+ menu = QMenu()
+ menu.addAction(_("Details"), lambda: self.main_window.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"))
+ key = tx_item['payment_hash']
+ log = self.wallet.lnworker.logs.get(key)
+ if log:
+ menu.addAction(_("View log"), lambda: self.main_window.send_tab.invoice_list.show_log(key, log))
+ menu.exec(self.viewport().mapToGlobal(position))
+ return
+ tx_hash = tx_item['txid']
+ tx = self.wallet.adb.get_transaction(tx_hash)
+ if not tx:
+ return
+ tx_URL = block_explorer_URL(self.config, 'tx', tx_hash)
+ tx_details = self.wallet.get_tx_info(tx)
+ is_unconfirmed = tx_details.tx_mined_status.height() <= 0
+ menu = QMenu()
+ menu.addAction(_("Details"), lambda: self.main_window.show_transaction(tx))
+ if tx_details.can_remove:
+ menu.addAction(_("Remove"), lambda: self.remove_local_tx(tx_hash))
+ copy_menu = self.add_copy_menu(menu, idx)
+ copy_menu.addAction(_("Transaction ID"), lambda: self.place_text_on_clipboard(tx_hash, title="TXID"))
+ menu_edit = menu.addMenu(_("Edit"))
+ for c in self.editable_columns:
+ if self.isColumnHidden(c):
+ continue
+ label = self.hm.headerData(c, Qt.Orientation.Horizontal, Qt.ItemDataRole.DisplayRole)
+ # TODO use siblingAtColumn when min Qt version is >=5.11
+ persistent = QPersistentModelIndex(org_idx.sibling(org_idx.row(), c))
+ menu_edit.addAction(_("{}").format(label), lambda p=persistent: self.edit(QModelIndex(p)))
+ channel_id = tx_item.get('channel_id')
+ if channel_id and self.wallet.lnworker and (chan := self.wallet.lnworker.get_channel_by_id(bytes.fromhex(channel_id))):
+ menu.addAction(_("View Channel"), lambda: self.main_window.show_channel_details(chan))
+ if is_unconfirmed and tx:
+ if tx_details.can_bump:
+ menu.addAction(_("Increase fee"), lambda: self.main_window.bump_fee_dialog(tx))
+ else:
+ if tx_details.can_cpfp:
+ menu.addAction(_("Child pays for parent"), lambda: self.main_window.cpfp_dialog(tx))
+ if tx_details.can_dscancel:
+ menu.addAction(_("Cancel (double-spend)"), lambda: self.main_window.dscancel_dialog(tx))
+ invoices = self.wallet.get_relevant_invoices_for_tx(tx_hash)
+ if len(invoices) == 1:
+ menu.addAction(_("View invoice"), lambda inv=invoices[0]: self.main_window.show_onchain_invoice(inv))
+ elif len(invoices) > 1:
+ menu_invs = menu.addMenu(_("Related invoices"))
+ for inv in invoices:
+ menu_invs.addAction(_("View invoice"), lambda inv=inv: self.main_window.show_onchain_invoice(inv))
+ if tx_URL:
+ menu.addAction(_("View on block explorer"), lambda: webopen(tx_URL))
+ self.open_menu(menu, position)
+
+ def remove_local_tx(self, tx_hash: str):
+ num_child_txs = len(self.wallet.adb.get_depending_transactions(tx_hash))
+ question = _("Are you sure you want to remove this transaction?")
+ if num_child_txs > 0:
+ question = (_("Are you sure you want to remove this transaction and {} child transactions?")
+ .format(num_child_txs))
+ if not self.main_window.question(msg=question, title=_("Please confirm")):
+ return
+ self.wallet.adb.remove_transaction(tx_hash)
+ self.wallet.save_db()
+ # need to update at least: history_list, utxo_list, address_list
+ self.main_window.need_update.set()
+
+ def onFileAdded(self, fn):
+ try:
+ with open(fn) as f:
+ tx = self.main_window.tx_from_text(f.read())
+ except IOError as e:
+ self.main_window.show_error(e)
+ return
+ if not tx:
+ return
+ self.main_window.save_transaction_into_wallet(tx)
+
+ def export_history_dialog(self):
+ d = WindowModalDialog(self, _('Export History'))
+ d.setMinimumSize(400, 200)
+ vbox = QVBoxLayout(d)
+ defaultname = f'electrum-history-{self.wallet.basename()}.csv'
+ select_msg = _('Select file to export your wallet transactions to')
+ hbox, filename_e, csv_button = filename_field(self, self.config, defaultname, select_msg)
+ vbox.addLayout(hbox)
+ vbox.addStretch(1)
+ hbox = Buttons(CancelButton(d), OkButton(d, _('Export')))
+ vbox.addLayout(hbox)
+ #run_hook('export_history_dialog', self, hbox)
+ self.update()
+ if not d.exec():
+ return
+ filename = filename_e.text()
+ if not filename:
+ return
+ try:
+ self.wallet.export_history_to_file(
+ fx=self.main_window.fx if self.hm.should_show_fiat() else None,
+ file_path=filename,
+ is_csv=csv_button.isChecked(),
+ )
+ except (IOError, os.error) as reason:
+ export_error_label = _("Electrum was unable to produce a transaction export.")
+ self.main_window.show_critical(export_error_label + "\n" + str(reason), title=_("Unable to export history"))
+ return
+ self.main_window.show_message(_("Your wallet history has been successfully exported."))
+
+ def get_text_from_coordinate(self, row, col):
+ return self.get_role_data_from_coordinate(row, col, role=Qt.ItemDataRole.DisplayRole)
+
+ def get_role_data_from_coordinate(self, row, col, *, role):
+ idx = self.model().mapToSource(self.model().index(row, col))
+ return self.hm.data(idx, role).value()
+
+
+HistoryColumns = HistoryList.Columns
diff --git a/electrum/gui/qt/invoice_list.py b/electrum/gui/qt/invoice_list.py
new file mode 100644
index 000000000000..dd8bfb35d298
--- /dev/null
+++ b/electrum/gui/qt/invoice_list.py
@@ -0,0 +1,218 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2015 Thomas Voegtlin
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import enum
+from typing import Sequence, TYPE_CHECKING
+
+from PyQt6.QtCore import Qt
+from PyQt6.QtGui import QStandardItemModel, QStandardItem
+from PyQt6.QtWidgets import QAbstractItemView
+from PyQt6.QtWidgets import QMenu, QVBoxLayout, QTreeWidget, QTreeWidgetItem, QHeaderView
+
+from electrum.i18n import _
+from electrum.util import format_time
+from electrum.invoices import PR_UNPAID, PR_INFLIGHT, PR_FAILED
+from electrum.lnutil import HtlcLog
+
+from .util import read_QIcon, pr_icons
+from .util import CloseButton, Buttons
+from .util import WindowModalDialog
+
+from .my_treeview import MyTreeView, MySortModel
+
+if TYPE_CHECKING:
+ from .send_tab import SendTab
+
+
+ROLE_REQUEST_TYPE = Qt.ItemDataRole.UserRole
+ROLE_REQUEST_ID = Qt.ItemDataRole.UserRole + 1
+ROLE_SORT_ORDER = Qt.ItemDataRole.UserRole + 2
+
+
+class InvoiceList(MyTreeView):
+ key_role = ROLE_REQUEST_ID
+
+ class Columns(MyTreeView.BaseColumnsEnum):
+ DATE = enum.auto()
+ DESCRIPTION = enum.auto()
+ AMOUNT = enum.auto()
+ STATUS = enum.auto()
+
+ headers = {
+ Columns.DATE: _('Date'),
+ Columns.DESCRIPTION: _('Description'),
+ Columns.AMOUNT: _('Amount'),
+ Columns.STATUS: _('Status'),
+ }
+ filter_columns = [Columns.DATE, Columns.DESCRIPTION, Columns.AMOUNT]
+
+ def __init__(self, send_tab: 'SendTab'):
+ window = send_tab.window
+ super().__init__(
+ main_window=window,
+ stretch_column=self.Columns.DESCRIPTION,
+ )
+ self.wallet = window.wallet
+ self.send_tab = send_tab
+ 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.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
+
+ def on_double_click(self, idx):
+ key = idx.sibling(idx.row(), self.Columns.DATE).data(ROLE_REQUEST_ID)
+ self.show_invoice(key)
+
+ def refresh_row(self, key, row):
+ assert row is not None
+ invoice = self.wallet.get_invoice(key)
+ if invoice is None:
+ return
+ model = self.std_model
+ status_item = model.item(row, self.Columns.STATUS)
+ status = self.wallet.get_invoice_status(invoice)
+ status_str = invoice.get_status_str(status)
+ if self.wallet.lnworker:
+ log = self.wallet.lnworker.logs.get(key)
+ if log and status == PR_INFLIGHT:
+ status_str += '... (%d)'%len(log)
+ status_item.setText(status_str)
+ status_item.setIcon(read_QIcon(pr_icons.get(status)))
+
+ def update(self):
+ # not calling maybe_defer_update() as it interferes with conditional-visibility
+ 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(self.wallet.get_unpaid_invoices()):
+ key = item.get_id()
+ if item.is_lightning():
+ icon_name = 'lightning.png'
+ else:
+ icon_name = 'bitcoin.png'
+ status = self.wallet.get_invoice_status(item)
+ amount = item.get_amount_sat()
+ amount_str = self.main_window.format_amount(amount, whitespaces=True) if amount else ""
+ amount_str_nots = self.main_window.format_amount(amount, whitespaces=True, add_thousands_sep=False) if amount else ""
+ timestamp = item.time or 0
+ labels = [""] * len(self.Columns)
+ labels[self.Columns.DATE] = format_time(timestamp) if timestamp else _('Unknown')
+ labels[self.Columns.DESCRIPTION] = item.message
+ labels[self.Columns.AMOUNT] = amount_str
+ labels[self.Columns.STATUS] = item.get_status_str(status)
+ items = [QStandardItem(e) for e in labels]
+ self.set_editability(items)
+ 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(item.type, role=ROLE_REQUEST_TYPE)
+ items[self.Columns.DATE].setData(timestamp, role=ROLE_SORT_ORDER)
+ items[self.Columns.AMOUNT].setData(amount_str_nots.strip(), role=self.ROLE_CLIPBOARD_DATA)
+ self.std_model.insertRow(idx, items)
+ self.filter()
+ self.proxy.setDynamicSortFilter(True)
+ # sort requests by date
+ self.sortByColumn(self.Columns.DATE, Qt.SortOrder.DescendingOrder)
+ self.hide_if_empty()
+
+ def show_invoice(self, key):
+ invoice = self.wallet.get_invoice(key)
+ if not invoice:
+ self.update()
+ return
+ if invoice.is_lightning():
+ self.main_window.show_lightning_invoice(invoice)
+ else:
+ self.main_window.show_onchain_invoice(invoice)
+
+ def hide_if_empty(self):
+ b = self.std_model.rowCount() > 0
+ self.setVisible(b)
+ self.send_tab.invoices_label.setVisible(b)
+
+ def create_menu(self, position):
+ wallet = self.wallet
+ items = self.selected_in_column(0)
+ if len(items) > 1:
+ keys = [item.data(ROLE_REQUEST_ID) for item in items]
+ invoices = [wallet.get_invoice(key) for key in keys]
+ can_batch_pay = all([not i.is_lightning() 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.send_tab.pay_multiple_invoices(invoices))
+ menu.addAction(_("Delete invoices"), lambda: self.delete_invoices(keys))
+ menu.exec(self.viewport().mapToGlobal(position))
+ return
+ idx = self.indexAt(position)
+ 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)
+ invoice = self.wallet.get_invoice(key)
+ menu = QMenu(self)
+ menu.addAction(_("Details"), lambda: self.show_invoice(key))
+ copy_menu = self.add_copy_menu(menu, idx)
+ address = invoice.get_address()
+ if address:
+ copy_menu.addAction(_("Address"), lambda: self.main_window.do_copy(invoice.get_address(), title='Bitcoin Address'))
+ status = wallet.get_invoice_status(invoice)
+ if status == PR_UNPAID:
+ if bool(invoice.get_amount_sat()):
+ menu.addAction(_("Pay") + "...", lambda: self.send_tab.do_pay_invoice(invoice))
+ else:
+ menu.addAction(_("Pay") + "...", lambda: self.send_tab.do_edit_invoice(invoice))
+ if status == PR_FAILED:
+ menu.addAction(_("Retry"), lambda: self.send_tab.do_pay_invoice(invoice))
+ if self.wallet.lnworker:
+ log = self.wallet.lnworker.logs.get(key)
+ if log:
+ menu.addAction(_("View log"), lambda: self.show_log(key, log))
+ menu.addAction(_("Delete"), lambda: self.delete_invoices([key]))
+ self.open_menu(menu, position)
+
+ def show_log(self, key, log: Sequence[HtlcLog]):
+ d = WindowModalDialog(self, _("Payment log"))
+ d.setMinimumWidth(600)
+ vbox = QVBoxLayout(d)
+ log_w = QTreeWidget()
+ log_w.setHeaderLabels([_('Hops'), _('Channel ID'), _('Message')])
+ log_w.header().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
+ log_w.header().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
+ for payment_attempt_log in log:
+ 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)))
+ d.exec()
+
+ def delete_invoices(self, keys):
+ for key in keys:
+ self.wallet.delete_invoice(key, write_to_disk=False)
+ self.delete_item(key)
+ self.wallet.save_db()
diff --git a/electrum/gui/qt/lightning_dialog.py b/electrum/gui/qt/lightning_dialog.py
new file mode 100644
index 000000000000..328f16716067
--- /dev/null
+++ b/electrum/gui/qt/lightning_dialog.py
@@ -0,0 +1,100 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2012 thomasv@gitorious
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from typing import TYPE_CHECKING
+
+from PyQt6.QtWidgets import (QDialog, QLabel, QVBoxLayout, QPushButton)
+
+from electrum.i18n import _
+
+from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
+
+from .util import Buttons
+
+if TYPE_CHECKING:
+ from . import ElectrumGui
+
+
+class LightningDialog(QDialog, QtEventListener):
+
+ def __init__(self, gui_object: 'ElectrumGui'):
+ QDialog.__init__(self)
+ self.gui_object = gui_object
+ self.config = gui_object.config
+ self.network = gui_object.daemon.network
+ assert self.network
+ self.setWindowTitle(_('Lightning Network'))
+ self.setMinimumWidth(600)
+ vbox = QVBoxLayout(self)
+ self.num_peers = QLabel('')
+ vbox.addWidget(self.num_peers)
+ self.num_nodes = QLabel('')
+ vbox.addWidget(self.num_nodes)
+ self.num_channels = QLabel('')
+ vbox.addWidget(self.num_channels)
+ self.status = QLabel('')
+ vbox.addWidget(self.status)
+ vbox.addStretch(1)
+ b = QPushButton(_('Close'))
+ b.clicked.connect(self.close)
+ vbox.addLayout(Buttons(b))
+ self.register_callbacks()
+ self.network.channel_db.update_counts() # trigger callback
+ if self.network.lngossip:
+ self.on_event_gossip_peers(self.network.lngossip.lnpeermgr.num_peers())
+ self.on_event_unknown_channels(len(self.network.lngossip.unknown_ids))
+ else:
+ self.num_peers.setText(_('Lightning gossip not active.'))
+
+ @qt_event_listener
+ def on_event_channel_db(self, num_nodes, num_channels, num_policies):
+ self.num_nodes.setText(_('{} nodes').format(num_nodes))
+ self.num_channels.setText(_('{} channels').format(num_channels))
+
+ @qt_event_listener
+ def on_event_gossip_peers(self, num_peers):
+ self.num_peers.setText(_('Connected to {} peers').format(num_peers))
+
+ @qt_event_listener
+ def on_event_unknown_channels(self, unknown):
+ self.status.setText(_('Requesting {} channels...').format(unknown) if unknown else '')
+
+ def is_hidden(self):
+ return self.isMinimized() or self.isHidden()
+
+ def show_or_hide(self):
+ if self.is_hidden():
+ self.bring_to_top()
+ else:
+ self.hide()
+
+ def bring_to_top(self):
+ self.show()
+ self.raise_()
+
+ def closeEvent(self, event):
+ self.unregister_callbacks()
+ self.gui_object.lightning_dialog = None
+ event.accept()
diff --git a/electrum/gui/qt/lightning_tx_dialog.py b/electrum/gui/qt/lightning_tx_dialog.py
new file mode 100644
index 000000000000..2ea94fd4d2ca
--- /dev/null
+++ b/electrum/gui/qt/lightning_tx_dialog.py
@@ -0,0 +1,97 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2020 The Electrum Developers
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from typing import TYPE_CHECKING
+from decimal import Decimal
+import datetime
+
+from PyQt6.QtWidgets import QVBoxLayout, QLabel
+
+from electrum.i18n import _
+from electrum.lnworker import PaymentDirection
+
+from .util import WindowModalDialog, ShowQRLineEdit, Buttons, CloseButton, font_height, ButtonsLineEdit
+from .qrtextedit import ShowQRTextEdit
+
+if TYPE_CHECKING:
+ from .main_window import ElectrumWindow
+
+
+class LightningTxDialog(WindowModalDialog):
+
+ def __init__(self, parent: 'ElectrumWindow', tx_item: dict):
+ WindowModalDialog.__init__(self, parent, _("Lightning Payment"))
+ self.main_window = parent
+ self.config = parent.config
+ self.label = tx_item['label']
+ self.timestamp = tx_item['timestamp']
+ self.amount = Decimal(tx_item['amount_msat']) / 1000
+ self.payment_hash = tx_item['payment_hash']
+ self.preimage = tx_item['preimage']
+ self.invoice = ""
+ invoice = self.main_window.wallet.get_invoice(self.payment_hash) # only check outgoing invoices
+ if invoice:
+ assert invoice.is_lightning(), f"{self.invoice!r}"
+ self.invoice = invoice.lightning_invoice
+ self.setMinimumWidth(700)
+ vbox = QVBoxLayout()
+ self.setLayout(vbox)
+ amount_str = self.main_window.format_amount_and_units(self.amount, timestamp=self.timestamp)
+ vbox.addWidget(QLabel(_("Amount") + f": {amount_str}"))
+ fee_msat = tx_item.get('fee_msat')
+ if fee_msat is not None:
+ fee_sat = Decimal(fee_msat) / 1000 if fee_msat is not None else None
+ fee_str = self.main_window.format_amount_and_units(fee_sat, timestamp=self.timestamp)
+ vbox.addWidget(QLabel(_("Fee: {}").format(fee_str)))
+ time_str = datetime.datetime.fromtimestamp(self.timestamp).isoformat(' ')[:-3]
+ vbox.addWidget(QLabel(_("Date") + ": " + time_str))
+ self.tx_desc_label = QLabel(_("Description:"))
+ vbox.addWidget(self.tx_desc_label)
+ self.tx_desc = ButtonsLineEdit(self.label)
+
+ def on_edited():
+ text = self.tx_desc.text()
+ if self.main_window.wallet.set_label(self.payment_hash, text):
+ self.main_window.history_list.update()
+ self.main_window.utxo_list.update()
+ self.main_window.labels_changed_signal.emit()
+ self.tx_desc.editingFinished.connect(on_edited)
+ self.tx_desc.addCopyButton()
+ vbox.addWidget(self.tx_desc)
+ vbox.addWidget(QLabel(_("Payment hash") + ":"))
+ self.hash_e = ShowQRLineEdit(self.payment_hash, self.config, title=_("Payment hash"))
+ vbox.addWidget(self.hash_e)
+ vbox.addWidget(QLabel(_("Preimage") + ":"))
+ self.preimage_e = ShowQRLineEdit(self.preimage, self.config, title=_("Preimage"))
+ vbox.addWidget(self.preimage_e)
+ if self.invoice:
+ vbox.addWidget(QLabel(_("Lightning Invoice") + ":"))
+ self.invoice_e = ShowQRTextEdit(self.invoice, config=self.config)
+ self.invoice_e.setMaximumHeight(max(150, 10 * font_height()))
+ self.invoice_e.addCopyButton()
+ vbox.addWidget(self.invoice_e)
+ self.close_button = CloseButton(self)
+ vbox.addLayout(Buttons(self.close_button))
+ self.close_button.setFocus()
diff --git a/electrum/gui/qt/locktimeedit.py b/electrum/gui/qt/locktimeedit.py
new file mode 100644
index 000000000000..f30a524c4e85
--- /dev/null
+++ b/electrum/gui/qt/locktimeedit.py
@@ -0,0 +1,192 @@
+# 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
+
+import time
+from datetime import datetime
+from typing import Optional, Any
+
+from PyQt6.QtCore import Qt, QDateTime, pyqtSignal
+from PyQt6.QtGui import QPainter
+from PyQt6.QtWidgets import (QWidget, QLineEdit, QStyle, QStyleOptionFrame, QComboBox,
+ QHBoxLayout, QDateTimeEdit)
+
+from electrum.i18n import _
+from electrum.bitcoin import NLOCKTIME_MIN, NLOCKTIME_MAX, NLOCKTIME_BLOCKHEIGHT_MAX
+
+from .util import char_width_in_lineedit, ColorScheme
+
+
+class LockTimeEdit(QWidget):
+
+ valueEdited = pyqtSignal()
+
+ def __init__(self, parent=None):
+ QWidget.__init__(self, parent)
+
+ hbox = QHBoxLayout()
+ self.setLayout(hbox)
+ hbox.setContentsMargins(0, 0, 0, 0)
+ hbox.setSpacing(0)
+
+ self.locktime_raw_e = LockTimeRawEdit(self)
+ self.locktime_height_e = LockTimeHeightEdit(self)
+ self.locktime_date_e = LockTimeDateEdit(self)
+ self.editors = [self.locktime_raw_e, self.locktime_height_e, self.locktime_date_e]
+
+ self.combo = QComboBox()
+ options = [_("Raw"), _("Block height"), _("Date")]
+ option_index_to_editor_map = {
+ 0: self.locktime_raw_e,
+ 1: self.locktime_height_e,
+ 2: self.locktime_date_e,
+ }
+ default_index = 1
+ self.combo.addItems(options)
+
+ def on_current_index_changed(i):
+ for w in self.editors:
+ w.setVisible(False)
+ w.setEnabled(False)
+ prev_locktime = self.editor.get_locktime()
+ self.editor = option_index_to_editor_map[i]
+ if self.editor.is_acceptable_locktime(prev_locktime):
+ self.editor.set_locktime(prev_locktime)
+ self.editor.setVisible(True)
+ self.editor.setEnabled(True)
+
+ self.editor = option_index_to_editor_map[default_index]
+ self.combo.currentIndexChanged.connect(on_current_index_changed)
+ self.combo.setCurrentIndex(default_index)
+ on_current_index_changed(default_index)
+
+ hbox.addWidget(self.combo)
+ for w in self.editors:
+ hbox.addWidget(w)
+ hbox.addStretch(1)
+
+ self.locktime_height_e.textEdited.connect(self.valueEdited.emit)
+ self.locktime_raw_e.textEdited.connect(self.valueEdited.emit)
+ self.locktime_date_e.dateTimeChanged.connect(self.valueEdited.emit)
+ self.combo.currentIndexChanged.connect(self.valueEdited.emit)
+
+ def get_locktime(self) -> Optional[int]:
+ return self.editor.get_locktime()
+
+ def set_locktime(self, x: Any) -> None:
+ self.editor.set_locktime(x)
+
+
+class _LockTimeEditor:
+ min_allowed_value = NLOCKTIME_MIN
+ max_allowed_value = NLOCKTIME_MAX
+
+ def get_locktime(self) -> Optional[int]:
+ raise NotImplementedError()
+
+ def set_locktime(self, x: Any) -> None:
+ raise NotImplementedError()
+
+ @classmethod
+ def is_acceptable_locktime(cls, x: Any) -> bool:
+ if not x: # e.g. empty string
+ return True
+ try:
+ x = int(x)
+ except Exception:
+ return False
+ return cls.min_allowed_value <= x <= cls.max_allowed_value
+
+
+class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
+
+ def __init__(self, parent=None):
+ QLineEdit.__init__(self, parent)
+ self.setFixedWidth(14 * char_width_in_lineedit())
+ self.textChanged.connect(self.numbify)
+
+ def numbify(self):
+ text = self.text().strip()
+ chars = '0123456789'
+ pos = self.cursorPosition()
+ pos = len(''.join([i for i in text[:pos] if i in chars]))
+ s = ''.join([i for i in text if i in chars])
+ self.set_locktime(s)
+ # setText sets Modified to False. Instead we want to remember
+ # if updates were because of user modification.
+ self.setModified(self.hasFocus())
+ self.setCursorPosition(pos)
+
+ def get_locktime(self) -> Optional[int]:
+ try:
+ return int(str(self.text()))
+ except Exception:
+ return None
+
+ def set_locktime(self, x: Any) -> None:
+ try:
+ x = int(x)
+ except Exception:
+ self.setText('')
+ return
+ x = max(x, self.min_allowed_value)
+ x = min(x, self.max_allowed_value)
+ self.setText(str(x))
+
+
+class LockTimeHeightEdit(LockTimeRawEdit):
+ max_allowed_value = NLOCKTIME_BLOCKHEIGHT_MAX
+
+ def __init__(self, parent=None):
+ LockTimeRawEdit.__init__(self, parent)
+ self.setFixedWidth(20 * char_width_in_lineedit())
+
+ def paintEvent(self, event):
+ super().paintEvent(event)
+ panel = QStyleOptionFrame()
+ self.initStyleOption(panel)
+ textRect = self.style().subElementRect(QStyle.SubElement.SE_LineEditContents, panel, self)
+ textRect.adjust(2, 0, -10, 0)
+ painter = QPainter(self)
+ painter.setPen(ColorScheme.GRAY.as_color())
+ painter.drawText(textRect, int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter), "height")
+
+
+def get_max_allowed_timestamp() -> int:
+ ts = NLOCKTIME_MAX
+ # Test if this value is within the valid timestamp limits (which is platform-dependent).
+ # see #6170
+ try:
+ datetime.fromtimestamp(ts)
+ except (OSError, OverflowError):
+ ts = 2 ** 31 - 1 # INT32_MAX
+ datetime.fromtimestamp(ts) # test if raises
+ return ts
+
+
+class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor):
+ min_allowed_value = NLOCKTIME_BLOCKHEIGHT_MAX + 1
+ max_allowed_value = get_max_allowed_timestamp()
+
+ def __init__(self, parent=None):
+ QDateTimeEdit.__init__(self, parent)
+ self.setMinimumDateTime(datetime.fromtimestamp(self.min_allowed_value))
+ self.setMaximumDateTime(datetime.fromtimestamp(self.max_allowed_value))
+ self.setDateTime(QDateTime.currentDateTime())
+
+ def get_locktime(self) -> Optional[int]:
+ dt = self.dateTime().toPyDateTime()
+ locktime = int(time.mktime(dt.timetuple()))
+ return locktime
+
+ def set_locktime(self, x: Any) -> None:
+ if not self.is_acceptable_locktime(x):
+ self.setDateTime(QDateTime.currentDateTime())
+ return
+ try:
+ x = int(x)
+ except Exception:
+ self.setDateTime(QDateTime.currentDateTime())
+ return
+ dt = datetime.fromtimestamp(x)
+ self.setDateTime(dt)
diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py
new file mode 100644
index 000000000000..81b5ca9b3b79
--- /dev/null
+++ b/electrum/gui/qt/main_window.py
@@ -0,0 +1,2991 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2012 thomasv@gitorious
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+import sys
+import time
+import threading
+import os
+import json
+import weakref
+import csv
+from decimal import Decimal
+import base64
+from functools import partial
+import queue
+import asyncio
+from typing import Optional, TYPE_CHECKING, Sequence, Union, Dict, Mapping, Callable, List, Set
+import concurrent.futures
+import inspect
+
+from PyQt6.QtGui import QPixmap, QKeySequence, QIcon, QCursor, QFont, QFontMetrics, QAction, QShortcut
+from PyQt6.QtCore import Qt, QRect, QStringListModel, QSize, pyqtSignal, QTimer
+from PyQt6.QtWidgets import (QMessageBox, QTabWidget, QMenuBar, QFileDialog, QCheckBox, QLabel,
+ QVBoxLayout, QGridLayout, QLineEdit, QHBoxLayout, QPushButton, QScrollArea, QTextEdit,
+ QMainWindow, QInputDialog, QWidget, QSizePolicy, QStatusBar, QToolTip,
+ QMenu, QToolButton, QDialog)
+
+import electrum_ecc as ecc
+
+import electrum
+from electrum.gui import messages
+from electrum import (keystore, constants, util, bitcoin, commands,
+ lnutil)
+from electrum.bitcoin import COIN, is_address, DummyAddress
+from electrum.plugin import run_hook
+from electrum.i18n import _
+from electrum.util import (format_time, UserCancelled, profiler, bfh, InvalidPassword,
+ UserFacingException, get_new_wallet_name,
+ send_exception_to_crash_reporter,
+ AddTransactionException, os_chmod, UI_UNIT_NAME_TXSIZE_VBYTES,
+ is_valid_email, ChoiceItem, event_listener)
+from electrum.bip21 import BITCOIN_BIP21_URI_SCHEME
+from electrum.payment_identifier import PaymentIdentifier
+from electrum.invoices import PR_PAID, Invoice
+from electrum.transaction import (Transaction, PartialTxInput, TxOutput,
+ PartialTransaction, PartialTxOutput)
+from electrum.wallet import (Multisig_Wallet, Abstract_Wallet,
+ sweep_preparations, InternalAddressCorruption,
+ CannotCPFP)
+from electrum.version import ELECTRUM_VERSION
+from electrum.network import Network, UntrustedServerReturnedError
+from electrum.exchange_rate import FxThread
+from electrum.simple_config import SimpleConfig
+from electrum.logging import Logger
+from electrum.lntransport import extract_nodeid, ConnStringFormatError
+from electrum.bolt11 import decode_bolt11_invoice, BOLT11Addr
+from electrum.submarine_swaps import SwapServerTransport, NostrTransport
+from electrum.fee_policy import FeePolicy
+
+from electrum.gui.common_qt.util import TaskThread, QtEventListener, qt_event_listener
+
+from .rate_limiter import rate_limited
+from .exception_window import Exception_Hook
+from .amountedit import BTCAmountEdit
+from .qrcodewidget import QRDialog
+from .qrtextedit import ShowQRTextEdit, ScanQRTextEdit, ScanShowQRTextEdit
+from .transaction_dialog import show_transaction
+from .fee_slider import FeeSlider, FeeComboBox
+from .util import (read_QIcon, ColorScheme, text_dialog, icon_path, WaitingDialog,
+ WindowModalDialog, HelpLabel, Buttons,
+ OkButton, InfoButton, WWLabel, CancelButton,
+ CloseButton, MessageBoxMixin, EnterButton, import_meta_gui, export_meta_gui,
+ filename_field, address_field, char_width_in_lineedit, webopen,
+ TRANSACTION_FILE_EXTENSION_FILTER_ANY, MONOSPACE_FONT,
+ getOpenFileName, getSaveFileName, ShowQRLineEdit, scan_qr_from_screenshot)
+from .wizard.wallet import WIF_HELP_TEXT
+from .history_list import HistoryList, HistoryModel
+from .update_checker import UpdateCheck, UpdateCheckThread
+from .channels_list import ChannelsList
+from .confirm_tx_dialog import ConfirmTxDialog, TxEditorContext
+from .rbf_dialog import BumpFeeDialog, DSCancelDialog
+from .qrreader import scan_qrcode_from_camera
+from .swap_dialog import SwapDialog, InvalidSwapParameters
+from .balance_dialog import (BalanceToolButton, COLOR_FROZEN, COLOR_UNMATURED, COLOR_UNCONFIRMED, COLOR_CONFIRMED,
+ COLOR_LIGHTNING, COLOR_FROZEN_LIGHTNING)
+
+if TYPE_CHECKING:
+ from . import ElectrumGui
+ from electrum.submarine_swaps import SwapOffer
+ from electrum.lnchannel import Channel
+
+
+class StatusBarButton(QToolButton):
+ # note: this class has a custom stylesheet applied in stylesheet_patcher.py
+ def __init__(self, icon, tooltip, func, sb_height):
+ QToolButton.__init__(self)
+ self.setText('')
+ self.setIcon(icon)
+ self.setToolTip(tooltip)
+ self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
+ self.setAutoRaise(True)
+ size = max(25, round(0.9 * sb_height))
+ self.setMaximumWidth(size)
+ self.clicked.connect(self.onPress)
+ self.func = func
+ self.setIconSize(QSize(size, size))
+ self.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
+
+ def onPress(self, checked=False):
+ '''Drops the unwanted PyQt "checked" argument'''
+ self.func()
+
+ def keyPressEvent(self, e):
+ if e.key() in [Qt.Key.Key_Return, Qt.Key.Key_Enter]:
+ self.func()
+
+
+def protected(func):
+ '''Password request wrapper. The password is passed to the function
+ as the 'password' named argument. "None" indicates either an
+ unencrypted wallet, or the user cancelled the password request.
+ An empty input is passed as the empty string.'''
+ def request_password(self, *args, **kwargs):
+ parent = self.top_level_window()
+ password = None
+ msg = kwargs.get('message')
+ while self._protected_requires_password():
+ password = self.wallet.get_unlocked_password() or self.password_dialog(parent=parent, msg=msg)
+ if password is None:
+ # User cancelled password input
+ return
+ try:
+ self.wallet.check_password(password)
+ break
+ except Exception as e:
+ self.show_error(str(e), parent=parent)
+ continue
+
+ kwargs['password'] = password
+ return func(self, *args, **kwargs)
+ return request_password
+
+
+class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
+
+ computing_privkeys_signal = pyqtSignal()
+ show_privkeys_signal = pyqtSignal()
+ show_error_signal = pyqtSignal(str)
+ show_message_signal = pyqtSignal(str)
+ labels_changed_signal = pyqtSignal()
+
+ def __init__(self, gui_object: 'ElectrumGui', wallet: Abstract_Wallet):
+ QMainWindow.__init__(self)
+ self.gui_object = gui_object
+ self.should_stop_wallet_on_close = True
+ self.config = config = gui_object.config # type: SimpleConfig
+ self.gui_thread = gui_object.gui_thread
+ assert wallet, "no wallet"
+ self.wallet = wallet
+ self._protected_requires_password = self.wallet.has_keystore_encryption
+ if wallet.has_lightning() and not self.config.cv.GUI_QT_SHOW_TAB_CHANNELS.is_set():
+ self.config.GUI_QT_SHOW_TAB_CHANNELS = True # override default, but still allow disabling tab manually
+
+ Exception_Hook.maybe_setup(config=self.config, wallet=self.wallet)
+
+ self.network = gui_object.daemon.network # type: Network
+ self.fx = gui_object.daemon.fx # type: FxThread
+ self.contacts = wallet.contacts
+ self.tray = gui_object.tray
+ self.app = gui_object.app
+ self._cleaned_up = False
+ self.qr_window = None
+ self.pluginsdialog = None
+ self.showing_cert_mismatch_error = False
+ self.tl_windows = []
+ Logger.__init__(self)
+
+ self._coroutines_scheduled = {} # type: Dict[concurrent.futures.Future, str]
+ self._coroutines_scheduled_lock = threading.Lock()
+ self.thread = TaskThread(self, self.on_error)
+
+ self.tx_notification_queue = queue.Queue()
+ self.tx_notification_last_time = 0
+
+ self.create_status_bar()
+ self.need_update = threading.Event()
+
+ self.completions = QStringListModel()
+
+ coincontrol_sb = self.create_coincontrol_statusbar()
+
+ self.tabs = tabs = QTabWidget(self)
+ self.send_tab = self.create_send_tab()
+ self.receive_tab = self.create_receive_tab()
+ self.addresses_tab = self.create_addresses_tab()
+ self.utxo_tab = self.create_utxo_tab()
+ self.console_tab = self.create_console_tab()
+ self.notes_tab = self.create_notes_tab()
+ self.contacts_tab = self.create_contacts_tab()
+ 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'))
+
+ def add_optional_tab(tabs, tab, icon, description):
+ tab.tab_icon = icon
+ tab.tab_description = description
+ tab.tab_pos = len(tabs)
+ if tab.is_shown_cv.get():
+ tabs.addTab(tab, icon, description.replace("&", ""))
+
+ add_optional_tab(tabs, self.addresses_tab, read_QIcon("tab_addresses.png"), _("&Addresses"))
+ add_optional_tab(tabs, self.channels_tab, read_QIcon("lightning.png"), _("Channels"))
+ add_optional_tab(tabs, self.utxo_tab, read_QIcon("tab_coins.png"), _("Co&ins"))
+ add_optional_tab(tabs, self.contacts_tab, read_QIcon("tab_contacts.png"), _("Con&tacts"))
+ add_optional_tab(tabs, self.console_tab, read_QIcon("tab_console.png"), _("Con&sole"))
+ add_optional_tab(tabs, self.notes_tab, read_QIcon("pen.png"), _("&Notes"))
+
+ tabs.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
+
+ central_widget = QScrollArea()
+ vbox = QVBoxLayout(central_widget)
+ vbox.setContentsMargins(0, 0, 0, 0)
+ vbox.addWidget(tabs)
+ vbox.addWidget(coincontrol_sb)
+
+ self.setCentralWidget(central_widget)
+
+ self.setMinimumWidth(640)
+ self.setMinimumHeight(400)
+ if self.config.GUI_QT_WINDOW_IS_MAXIMIZED:
+ self.showMaximized()
+
+ self.setWindowIcon(read_QIcon("electrum.png"))
+ self.init_menubar()
+
+ wrtabs = weakref.proxy(tabs)
+ QShortcut(QKeySequence("Ctrl+W"), self, self.close)
+ QShortcut(QKeySequence("Ctrl+Q"), self, self.close)
+ QShortcut(QKeySequence("Ctrl+R"), self, self.update_wallet)
+ QShortcut(QKeySequence("F5"), self, self.update_wallet)
+ QShortcut(QKeySequence("Ctrl+PgUp"), self, lambda: wrtabs.setCurrentIndex((wrtabs.currentIndex() - 1)%wrtabs.count()))
+ QShortcut(QKeySequence("Ctrl+PgDown"), self, lambda: wrtabs.setCurrentIndex((wrtabs.currentIndex() + 1)%wrtabs.count()))
+
+ for i in range(wrtabs.count()):
+ QShortcut(QKeySequence("Alt+" + str(i + 1)), self, lambda i=i: wrtabs.setCurrentIndex(i))
+
+ self.app.refresh_tabs_signal.connect(self.refresh_tabs)
+ self.app.refresh_amount_edits_signal.connect(self.refresh_amount_edits)
+ self.app.update_status_signal.connect(self.update_status)
+ self.app.update_fiat_signal.connect(self.update_fiat)
+
+ self.show_error_signal.connect(self.show_error)
+ self.show_message_signal.connect(self.show_message)
+ self.history_list.setFocus()
+
+ # network callbacks
+ self.register_callbacks()
+ # wallet closing warning callbacks
+ self.closing_warning_callbacks = [] # type: List[Callable[[], Optional[str]]]
+ self.register_closing_warning_callback(self._check_ongoing_submarine_swaps_callback)
+ self.register_closing_warning_callback(self._check_ongoing_force_closures)
+ # banner may already be there
+ if self.network and self.network.banner:
+ self.console.showMessage(self.network.banner)
+
+ # update fee slider in case we missed the callback
+ #self.fee_slider.update()
+ self.load_wallet(wallet)
+
+ self.timer = QTimer(self)
+ self.timer.setInterval(500)
+ self.timer.setSingleShot(False)
+ self.timer.timeout.connect(self.timer_actions)
+ self.timer.start()
+
+ self.contacts.fetch_openalias(self.config)
+
+ # If the option hasn't been set yet
+ if not config.cv.AUTOMATIC_CENTRALIZED_UPDATE_CHECKS.is_set():
+ choice = self.question(title="Electrum - " + _("Enable update check"),
+ msg=_("For security reasons we advise that you always use the latest version of Electrum.") + " " +
+ _("Would you like to be notified when there is a newer version of Electrum available?"))
+ config.AUTOMATIC_CENTRALIZED_UPDATE_CHECKS = bool(choice)
+
+ self._update_check_thread = None
+ if config.AUTOMATIC_CENTRALIZED_UPDATE_CHECKS:
+ # The references to both the thread and the window need to be stored somewhere
+ # to prevent GC from getting in our way.
+ def on_version_received(v):
+ if UpdateCheck.is_newer(v):
+ 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._update_check_thread.checked.connect(on_version_received)
+ self._update_check_thread.start()
+
+ def run_coroutine_dialog(self, coro, text):
+ """ run coroutine in a waiting dialog, with a Cancel button that cancels the coroutine"""
+ from .util import RunCoroutineDialog
+ d = RunCoroutineDialog(self, text, coro)
+ return d.run()
+
+ def run_coroutine_from_thread(self, coro, name, on_result=None):
+ if self._cleaned_up:
+ self.logger.warning(f"stopping or already stopped but run_coroutine_from_thread was called.")
+ return
+ async def wrapper():
+ try:
+ res = await coro
+ except Exception as e:
+ self.logger.exception("exception in coro scheduled via window.wallet")
+ self.show_error_signal.emit(repr(e))
+ else:
+ if on_result:
+ on_result(res)
+ finally:
+ with self._coroutines_scheduled_lock:
+ self._coroutines_scheduled.pop(fut)
+ self.need_update.set()
+
+ fut = asyncio.run_coroutine_threadsafe(wrapper(), self.network.asyncio_loop)
+ with self._coroutines_scheduled_lock:
+ self._coroutines_scheduled[fut] = name
+ self.need_update.set()
+
+ def toggle_lock(self):
+ if self.wallet.get_unlocked_password():
+ self.lock_wallet()
+ else:
+ msg = ' '.join([
+ _('Your wallet is locked.'),
+ _('If you unlock it, its password will not be required to sign transactions.'),
+ _('Enter your password to unlock your wallet:')
+ ])
+ self.unlock_wallet(message=msg)
+
+ def update_lock_menu(self):
+ self.lock_menu.setEnabled(self._protected_requires_password())
+ text = _('Lock') if self.wallet.get_unlocked_password() else _('Unlock')
+ self.lock_menu.setText(text)
+
+ @protected
+ def unlock_wallet(self, password, message=None):
+ self.wallet.unlock(password)
+ self.update_lock_icon()
+ self.update_lock_menu()
+ self.wallet.txbatcher.set_password_future(password)
+ icon = read_QIcon("unlock.png")
+ msg = ' '.join([
+ _('Your wallet is unlocked.'),
+ _('Its password will not be required to sign transactions.'),
+ ])
+ self.show_message(msg, icon=icon.pixmap(30))
+
+ def lock_wallet(self):
+ self.wallet.lock_wallet()
+ self.update_lock_icon()
+ self.update_lock_menu()
+ icon = read_QIcon("lock.png")
+ msg = ' '.join([
+ _('Your wallet is locked.'),
+ _('Its password will be required to sign transactions.'),
+ ])
+ self.show_message(msg, icon=icon.pixmap(30))
+
+ def on_fx_history(self):
+ self.history_model.refresh('fx_history')
+ self.address_list.refresh_all()
+
+ def on_fx_quotes(self):
+ self.update_status()
+ # Refresh edits with the new rate
+ edit = self.send_tab.fiat_send_e if self.send_tab.fiat_send_e.is_last_edited else self.send_tab.amount_e
+ edit.textEdited.emit(edit.text())
+ edit = self.receive_tab.fiat_receive_e if self.receive_tab.fiat_receive_e.is_last_edited else self.receive_tab.receive_amount_e
+ edit.textEdited.emit(edit.text())
+ # History tab needs updating if it used spot
+ if self.fx.history_used_spot:
+ self.history_model.refresh('fx_quotes')
+ self.address_list.refresh_all()
+
+ def toggle_tab(self, tab):
+ show = not tab.is_shown_cv.get()
+ tab.is_shown_cv.set(show)
+ if show:
+ # Find out where to place the tab
+ index = len(self.tabs)
+ for i in range(len(self.tabs)):
+ try:
+ if tab.tab_pos < self.tabs.widget(i).tab_pos:
+ index = i
+ break
+ except AttributeError:
+ pass
+ self.tabs.insertTab(index, tab, tab.tab_icon, tab.tab_description.replace("&", ""))
+ else:
+ i = self.tabs.indexOf(tab)
+ self.tabs.removeTab(i)
+
+ def push_top_level_window(self, window):
+ '''Used for e.g. tx dialog box to ensure new dialogs are appropriately
+ parented. This used to be done by explicitly providing the parent
+ window, but that isn't something hardware wallet prompts know.'''
+ self.tl_windows.append(window)
+
+ def pop_top_level_window(self, window):
+ self.tl_windows.remove(window)
+
+ def top_level_window(self, test_func=None):
+ '''Do the right thing in the presence of tx dialog windows'''
+ override = self.tl_windows[-1] if self.tl_windows else None
+ if override and test_func and not test_func(override):
+ override = None # only override if ok for test_func
+ return self.top_level_window_recurse(override, test_func)
+
+ def diagnostic_name(self):
+ #return '{}:{}'.format(self.__class__.__name__, self.wallet.diagnostic_name())
+ return self.wallet.diagnostic_name()
+
+ def is_hidden(self):
+ return self.isMinimized() or self.isHidden()
+
+ def show_or_hide(self):
+ if self.is_hidden():
+ self.bring_to_top()
+ else:
+ self.hide()
+
+ def bring_to_top(self):
+ self.show()
+ self.raise_()
+
+ def on_error(self, exc_info):
+ e = exc_info[1]
+ if isinstance(e, (UserCancelled, concurrent.futures.CancelledError)):
+ pass
+ elif isinstance(e, UserFacingException):
+ self.show_error(str(e))
+ else:
+ # TODO would be nice if we just sent these to the crash reporter...
+ # anything we don't want to send there, we should explicitly catch
+ # send_exception_to_crash_reporter(e)
+ try:
+ self.logger.error("on_error", exc_info=exc_info)
+ except OSError:
+ pass # see #4418
+ self.show_error(repr(e))
+
+ @event_listener
+ def on_event_wallet_updated(self, wallet):
+ if wallet == self.wallet:
+ self.need_update.set()
+
+ @event_listener
+ def on_event_new_transaction(self, wallet: Abstract_Wallet, tx: Transaction):
+ if wallet == self.wallet:
+ self.tx_notification_queue.put(tx)
+ self.need_update.set()
+
+ @qt_event_listener
+ def on_event_password_required(self, wallet):
+ if wallet == self.wallet:
+ self.password_required_button.show()
+
+ @qt_event_listener
+ def on_event_password_not_required(self, wallet):
+ if wallet == self.wallet:
+ self.password_required_button.hide()
+
+ def on_password_required_button_clicked(self):
+ if self.wallet.txbatcher.password_future is None:
+ return
+ txids = self.wallet.txbatcher.password_future.txids
+ labels = [ ' - %s ' % (self.wallet.get_label_for_txid(txid) or (txid[0:15] + '...')) for txid in txids ]
+ message = _('Your password is needed to sign the following transactions:') + '\n' + '\n'.join(labels)
+ password = self.get_password(message=message)
+ if password:
+ self.wallet.txbatcher.set_password_future(password)
+
+ @qt_event_listener
+ def on_event_status(self):
+ self.update_status()
+
+ @qt_event_listener
+ def on_event_network_updated(self, *args):
+ self.update_status()
+
+ @qt_event_listener
+ def on_event_blockchain_updated(self, *args):
+ # update the number of confirmations in history
+ self.refresh_tabs()
+
+ @qt_event_listener
+ def on_event_on_quotes(self, *args):
+ self.on_fx_quotes()
+
+ @qt_event_listener
+ def on_event_on_history(self, *args):
+ self.on_fx_history()
+
+ @qt_event_listener
+ def on_event_gossip_db_loaded(self, *args):
+ self.channels_list.gossip_db_loaded.emit(*args)
+
+ @qt_event_listener
+ def on_event_channels_updated(self, *args):
+ wallet = args[0]
+ if wallet == self.wallet:
+ self.channels_list.update_rows.emit(*args)
+
+ @qt_event_listener
+ def on_event_channel(self, *args):
+ wallet = args[0]
+ if wallet == self.wallet:
+ self.channels_list.update_single_row.emit(*args)
+ self.update_status()
+
+ @qt_event_listener
+ def on_event_banner(self, *args):
+ self.console.showMessage(args[0])
+
+ @qt_event_listener
+ def on_event_adb_set_future_tx(self, adb, txid):
+ if adb == self.wallet.adb:
+ self.history_model.refresh('set_future_tx')
+ self.utxo_list.refresh_all() # for coin frozen status
+ self.update_status() # frozen balance
+
+ @qt_event_listener
+ def on_event_verified(self, *args):
+ wallet, tx_hash, tx_mined_status = args
+ if wallet == self.wallet:
+ self.history_model.update_tx_mined_status(tx_hash, tx_mined_status)
+
+ @qt_event_listener
+ def on_event_fee_histogram(self, *args):
+ self.history_model.on_fee_histogram()
+
+ @qt_event_listener
+ def on_event_ln_gossip_sync_progress(self, *args):
+ self.update_lightning_icon()
+
+ @qt_event_listener
+ def on_event_cert_mismatch(self, *args):
+ self.show_cert_mismatch_error()
+
+ @qt_event_listener
+ def on_event_tor_probed(self, is_tor):
+ self.tor_button.setVisible(is_tor)
+
+ @qt_event_listener
+ def on_event_proxy_set(self, *args):
+ self.tor_button.setVisible(False)
+
+ @qt_event_listener
+ def on_event_recently_opened_wallets_update(self, *args):
+ self.update_recently_opened_menu()
+
+ def close_wallet(self):
+ if self.wallet:
+ self.logger.info(f'close_wallet {self.wallet.storage.get_path()}')
+ run_hook('close_wallet', self.wallet)
+
+ @profiler
+ def load_wallet(self, wallet: Abstract_Wallet):
+ self.update_recently_opened_menu()
+ 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
+ self.seed_menu.setEnabled(self.wallet.has_seed())
+ self.update_lock_icon()
+ self.update_buttons_on_seed()
+ self.update_console()
+ self.receive_tab.do_clear()
+ self.receive_tab.request_list.update()
+ self.channels_list.update()
+ self.tabs.show()
+ self.init_geometry()
+ if self.config.GUI_QT_HIDE_ON_STARTUP and self.gui_object.tray.isVisible():
+ self.hide()
+ else:
+ self.show()
+ self.watching_only_changed()
+ run_hook('load_wallet', wallet, self)
+ try:
+ wallet.try_detecting_internal_addresses_corruption()
+ except InternalAddressCorruption as e:
+ self.show_error(str(e))
+ send_exception_to_crash_reporter(e)
+
+ def init_geometry(self):
+ # note: does not support multiple monitors well
+ winpos = self.wallet.db.get("winpos-qt")
+ try:
+ winrect = QRect(*winpos)
+ except TypeError:
+ winrect = None
+ screen = self.app.primaryScreen().geometry()
+ if winrect and screen.contains(winrect):
+ self.setGeometry(winrect)
+ else:
+ self.logger.info("using default geometry")
+ self.setGeometry(100, 100, 840, 400)
+
+ @classmethod
+ def get_app_name_and_version_str(cls) -> str:
+ name = "Electrum"
+ if constants.net.TESTNET:
+ name += " " + constants.net.NET_NAME.capitalize()
+ return f"{name} {ELECTRUM_VERSION}"
+
+ def watching_only_changed(self):
+ name_and_version = self.get_app_name_and_version_str()
+ title = f"{name_and_version} - {self.wallet.basename()}"
+ extra = [self.wallet.db.get('wallet_type', '?')]
+ if self.wallet.is_watching_only():
+ extra.append(_('watching only'))
+ title += ' [%s]'% ', '.join(extra)
+ self.setWindowTitle(title)
+ self.password_menu.setEnabled(self.wallet.may_have_password())
+ self.import_privkey_menu.setVisible(self.wallet.can_import_privkey())
+ self.import_address_menu.setVisible(self.wallet.can_import_address())
+ self.export_menu.setEnabled(self.wallet.can_export())
+
+ def warn_if_watching_only(self):
+ if self.wallet.is_watching_only():
+ msg = ' '.join([
+ _("This wallet is watching-only."),
+ _("This means you will not be able to spend Bitcoins with it."),
+ _("Make sure you own the seed phrase or the private keys, before you request Bitcoins to be sent to this wallet.")
+ ])
+ self.show_warning(msg, title=_('Watch-only wallet'))
+
+ def warn_if_testnet(self):
+ if not constants.net.TESTNET:
+ return
+ # user might have opted out already
+ if self.config.DONT_SHOW_TESTNET_WARNING:
+ return
+ # only show once per process lifecycle
+ if getattr(self.gui_object, '_warned_testnet', False):
+ return
+ self.gui_object._warned_testnet = True
+ msg = ''.join([
+ _("You are in testnet mode."), ' ',
+ _("Testnet coins are worthless."), '\n',
+ _("Testnet is separate from the main Bitcoin network. It is used for testing.")
+ ])
+ cb = QCheckBox(_("Don't show this again."))
+ cb_checked = False
+ def on_cb(_x):
+ nonlocal cb_checked
+ cb_checked = cb.isChecked()
+ cb.stateChanged.connect(on_cb)
+ self.show_warning(msg, title=_('Testnet'), checkbox=cb)
+ if cb_checked:
+ self.config.DONT_SHOW_TESTNET_WARNING = True
+
+ def open_wallet(self):
+ try:
+ wallet_folder = self.get_wallet_folder()
+ except FileNotFoundError as e:
+ self.show_error(str(e))
+ return
+ filename, __ = QFileDialog.getOpenFileName(self, "Select your wallet file", wallet_folder)
+ if not filename:
+ return
+ self.gui_object.new_window(filename)
+
+ def select_backup_dir(self, b):
+ name = self.config.WALLET_BACKUP_DIRECTORY or ""
+ dirname = QFileDialog.getExistingDirectory(self, "Select your wallet backup directory", name)
+ if dirname:
+ self.config.WALLET_BACKUP_DIRECTORY = dirname
+ self.backup_dir_e.setText(dirname)
+
+ def backup_wallet(self):
+ d = WindowModalDialog(self, _("File Backup"))
+ vbox = QVBoxLayout(d)
+ grid = QGridLayout()
+ backup_help = ""
+ backup_dir = self.config.WALLET_BACKUP_DIRECTORY
+ 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 False
+ backup_dir = self.config.get_backup_dir()
+ if backup_dir is None:
+ self.show_message(_("You need to configure a backup directory in your preferences"), title=_("Backup not configured"))
+ return
+ try:
+ new_path = self.wallet.save_backup(backup_dir)
+ 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
+ msg = _("A copy of your wallet file was created in")+" '%s'" % str(new_path)
+ self.show_message(msg, title=_("Wallet backup created"))
+ return True
+
+ def update_recently_opened_menu(self):
+ recent = self.config.RECENTLY_OPEN_WALLET_FILES or []
+ self.recently_visited_menu.clear()
+ for i, k in enumerate(recent):
+ b = os.path.basename(k)
+
+ def loader(k):
+ return lambda: self.gui_object.new_window(k)
+ self.recently_visited_menu.addAction(b, loader(k)).setShortcut(QKeySequence("Ctrl+%d" % (i+1)))
+ self.recently_visited_menu.setEnabled(bool(len(recent)))
+
+ def get_wallet_folder(self):
+ return os.path.abspath(self.config.get_datadir_wallet_path())
+
+ def new_wallet(self):
+ try:
+ wallet_folder = self.get_wallet_folder()
+ except FileNotFoundError as e:
+ self.show_error(str(e))
+ return
+ try:
+ filename = get_new_wallet_name(wallet_folder)
+ except OSError as e:
+ self.logger.exception("")
+ self.show_error(repr(e))
+ path = self.config.get_fallback_wallet_path()
+ else:
+ path = os.path.join(wallet_folder, filename)
+ self.gui_object.start_new_window(path, uri=None, force_wizard=True)
+
+ def init_menubar(self):
+ menubar = QMenuBar()
+
+ self.file_menu = menubar.addMenu(_("&File"))
+ self.recently_visited_menu = self.file_menu.addMenu(_("&Recently open"))
+ self.file_menu.addAction(_("&Open"), self.open_wallet).setShortcut(QKeySequence.StandardKey.Open)
+ self.file_menu.addAction(_("&New/Restore"), self.new_wallet).setShortcut(QKeySequence.StandardKey.New)
+ self.file_menu.addAction(_("&Save backup"), self.backup_wallet).setShortcut(QKeySequence.StandardKey.SaveAs)
+ self.file_menu.addAction(_("Delete"), self.remove_wallet)
+ self.file_menu.addSeparator()
+ self.file_menu.addAction(_("&Quit"), self.close)
+
+ self.wallet_menu = menubar.addMenu(_("&Wallet"))
+ self.wallet_menu.addAction(_("&Information"), self.show_wallet_info)
+ self.wallet_menu.addSeparator()
+
+ self.password_menu = self.wallet_menu.addAction(_("&Password"), self.change_password_dialog)
+ self.lock_menu = self.wallet_menu.addAction(_("&Unlock"), self.toggle_lock)
+ self.update_lock_menu()
+ self.seed_menu = self.wallet_menu.addAction(_("&Seed"), self.show_seed_dialog)
+ self.private_keys_menu = self.wallet_menu.addMenu(_("&Private keys"))
+ self.private_keys_menu.addAction(_("&Sweep"), self.sweep_key_dialog)
+ self.import_privkey_menu = self.private_keys_menu.addAction(_("&Import"), self.do_import_privkey)
+ self.export_menu = self.private_keys_menu.addAction(_("&Export"), self.export_privkeys_dialog)
+ self.import_address_menu = self.wallet_menu.addAction(_("Import addresses"), self.import_addresses)
+
+ self.labels_menu = self.wallet_menu.addMenu(_("&Labels"))
+ self.labels_menu.addAction(_("&Import"), self.do_import_labels)
+ self.labels_menu.addAction(_("&Export"), self.do_export_labels)
+
+ self.wallet_menu.addAction(_("Find"), self.toggle_search).setShortcut(QKeySequence("Ctrl+F"))
+ self.wallet_menu.addSeparator()
+
+ def add_toggle_action(tab):
+ is_shown = tab.is_shown_cv.get()
+ tab.menu_action = self.view_menu.addAction(tab.tab_description, lambda: self.toggle_tab(tab))
+ tab.menu_action.setCheckable(True)
+ tab.menu_action.setChecked(is_shown)
+ self.view_menu = menubar.addMenu(_("&View"))
+ add_toggle_action(self.addresses_tab)
+ add_toggle_action(self.utxo_tab)
+ add_toggle_action(self.channels_tab)
+ add_toggle_action(self.contacts_tab)
+ add_toggle_action(self.console_tab)
+ add_toggle_action(self.notes_tab)
+
+ self.tools_menu = menubar.addMenu(_("&Tools")) # type: QMenu
+ preferences_action = self.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.MenuRole.PreferencesRole) # make sure OS recognizes it as preferences
+ # Add another preferences item, to also have a "uniform location for Electrum between different OSes"
+ self.tools_menu.addAction(_("Electrum preferences"), self.settings_dialog)
+
+ self.tools_menu.addAction(_("&Network"), self.gui_object.show_network_dialog).setEnabled(bool(self.network))
+ self.tools_menu.addAction(_("&Plugins"), self.gui_object.show_plugins_dialog)
+ self.tools_menu.addSeparator()
+ self.tools_menu.addAction(_("&Sign/verify message"), self.sign_verify_message)
+ self.tools_menu.addAction(_("&Encrypt/decrypt message"), self.encrypt_message)
+ self.tools_menu.addSeparator()
+
+ raw_transaction_menu = self.tools_menu.addMenu(_("&Load transaction"))
+ raw_transaction_menu.addAction(_("&From file"), self.do_process_from_file)
+ raw_transaction_menu.addAction(_("&From text"), self.do_process_from_text)
+ raw_transaction_menu.addAction(_("&From the blockchain"), self.do_process_from_txid)
+ raw_transaction_menu.addAction(_("&From QR code"), self.read_tx_from_qrcode)
+ self.raw_transaction_menu = raw_transaction_menu
+
+ self.help_menu = menubar.addMenu(_("&Help"))
+ if sys.platform != 'darwin':
+ self.help_menu.addAction(_("&About"), self.show_about)
+ else:
+ # macOS reserves the "About" menu item name, similarly to "Preferences" (see above).
+ # The "About" keyword seems even more strictly locked down:
+ # not allowed as either a prefix or a suffix.
+ about_action = QAction(self)
+ about_action.triggered.connect(self.show_about)
+ about_action.setMenuRole(QAction.MenuRole.AboutRole) # make sure OS recognizes it as "About"
+ self.help_menu.addAction(about_action)
+ self.help_menu.addAction(_("&Changelog"), lambda: webopen(constants.RELEASE_NOTES_URL))
+ self.help_menu.addAction(_("&Check for updates"), self.show_update_check)
+ self.help_menu.addAction(_("&Official website"), lambda: webopen("https://electrum.org"))
+ self.help_menu.addSeparator()
+ self.help_menu.addAction(_("&Documentation"), lambda: webopen("http://docs.electrum.org/")).setShortcut(QKeySequence.StandardKey.HelpContents)
+ if not constants.net.TESTNET:
+ self.help_menu.addAction(_("&Bitcoin Paper"), self.show_bitcoin_paper)
+ self.help_menu.addAction(_("&Report Bug"), self.show_report_bug)
+ self.help_menu.addSeparator()
+ if self.network:
+ self.help_menu.addAction(_("&Donate to server"), self.donate_to_server)
+
+ run_hook('init_menubar', self)
+ self.setMenuBar(menubar)
+
+ def donate_to_server(self):
+ d = self.network.get_donation_address()
+ if d:
+ self.show_send_tab()
+ host = self.network.get_parameters().server.host
+ self.handle_payment_identifier('bitcoin:%s?message=donation for %s' % (d, host))
+ else:
+ self.show_error(_('No donation address for this server'))
+
+ def show_about(self):
+ QMessageBox.about(self, "Electrum",
+ (_("Version")+" %s" % ELECTRUM_VERSION + "\n\n" +
+ _("Electrum's focus is speed, with low resource usage and simplifying Bitcoin.") + " " +
+ _("You do not need to perform regular backups, because your wallet can be "
+ "recovered from a secret phrase that you can memorize or write on paper.") + " " +
+ _("Startup times are instant because it operates in conjunction with high-performance "
+ "servers that handle the most complicated parts of the Bitcoin system.") + "\n\n" +
+ _("Uses icons from the Icons8 icon pack (icons8.com).")))
+
+ def show_bitcoin_paper(self):
+ filename = os.path.join(self.config.path, 'bitcoin.pdf')
+ if not os.path.exists(filename):
+ def fetch_bitcoin_paper():
+ s = self._fetch_tx_from_network("54e48e5f5c656b26c3bca14a8c95aa583d07ebe84dde3b7dd4a78f4e4186e713")
+ if not s:
+ raise concurrent.futures.CancelledError
+ s = s.split("0100000000000000")[1:-1]
+ out = ''.join(x[6:136] + x[138:268] + x[270:400] if len(x) > 136 else x[6:] for x in s)[16:-20]
+ with open(filename, 'wb') as f:
+ f.write(bytes.fromhex(out))
+ WaitingDialog(
+ self,
+ _("Fetching Bitcoin Paper..."),
+ fetch_bitcoin_paper,
+ on_success=lambda _: webopen('file:///' + filename),
+ on_error=self.on_error,
+ )
+ return
+ webopen('file:///' + filename)
+
+ def show_update_check(self, version=None):
+ self.gui_object._update_check = UpdateCheck(latest_version=version)
+
+ def show_report_bug(self):
+ msg = ' '.join([
+ _("Please report any bugs as issues on github: "),
+ f'''{constants.GIT_REPO_ISSUES_URL}
''',
+ _("Before reporting a bug, upgrade to the most recent version of Electrum (latest release or git HEAD), and include the version number in your report."),
+ _("Try to explain not only what the bug is, but how it occurs.")
+ ])
+ self.show_message(msg, title="Electrum - " + _("Reporting Bugs"), rich_text=True)
+
+ def notify_transactions(self):
+ if self.tx_notification_queue.qsize() == 0:
+ return
+ if not self.wallet.is_up_to_date():
+ return # no notifications while syncing
+ now = time.time()
+ rate_limit = 20 # seconds
+ if self.tx_notification_last_time + rate_limit > now:
+ return
+ self.tx_notification_last_time = now
+ self.logger.info("Notifying GUI about new transactions")
+ txns = []
+ while True:
+ try:
+ txns.append(self.tx_notification_queue.get_nowait())
+ except queue.Empty:
+ break
+
+ for notification in self.wallet.get_user_notifications_for_new_txns(txns):
+ self.notify(notification)
+
+ def notify(self, message):
+ if self.tray:
+ self.tray.showMessage("Electrum", message, read_QIcon("electrum_dark_icon"), 20000)
+
+ def timer_actions(self):
+ # refresh invoices and requests because they show ETA
+ self.receive_tab.request_list.refresh_all()
+ self.send_tab.invoice_list.refresh_all()
+ # Note this runs in the GUI thread
+ if self.need_update.is_set():
+ self.need_update.clear()
+ self.update_wallet()
+ elif not self.wallet.is_up_to_date():
+ # this updates "synchronizing" progress
+ self.update_status()
+ # resolve aliases
+ # FIXME this might do blocking network calls that has a timeout of several seconds
+ # self.send_tab.payto_e.on_timer_check_text()
+ self.notify_transactions()
+
+ def format_amount(
+ self,
+ amount_sat,
+ is_diff=False,
+ whitespaces=False,
+ *,
+ add_thousands_sep: bool = None,
+ ) -> str:
+ """Formats amount as string, converting to desired unit.
+ E.g. 500_000 -> '0.005'
+ """
+ return self.config.format_amount(
+ amount_sat,
+ is_diff=is_diff,
+ whitespaces=whitespaces,
+ add_thousands_sep=add_thousands_sep,
+ )
+
+ def format_amount_and_units(self, amount_sat, *, timestamp: int = None) -> str:
+ """Returns string with both bitcoin and fiat amounts, in desired units.
+ E.g. 500_000 -> '0.005 BTC (191.42 EUR)'
+ """
+ text = self.config.format_amount_and_units(amount_sat)
+ fiat = self.fx.format_amount_and_units(amount_sat, timestamp=timestamp) if self.fx else None
+ if text and fiat:
+ text += f' ({fiat})'
+ return text
+
+ def format_fiat_and_units(self, amount_sat) -> str:
+ """Returns string of FX fiat amount, in desired units.
+ E.g. 500_000 -> '191.42 EUR'
+ """
+ return self.fx.format_amount_and_units(amount_sat) if self.fx else ''
+
+ def format_fee_rate(self, fee_rate) -> str:
+ """fee_rate is in sat/kvByte."""
+ return self.config.format_fee_rate(fee_rate)
+
+ def get_decimal_point(self):
+ return self.config.BTC_AMOUNTS_DECIMAL_POINT
+
+ def base_unit(self):
+ return self.config.get_base_unit()
+
+ def connect_fields(self, btc_e, fiat_e):
+
+ def edit_changed(edit):
+ if edit.follows:
+ return
+ edit.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet())
+ fiat_e.is_last_edited = (edit == fiat_e)
+ amount = edit.get_amount()
+ rate = self.fx.exchange_rate() if self.fx else Decimal('NaN')
+ if rate.is_nan() or amount is None:
+ if edit is fiat_e:
+ btc_e.setText("")
+ else:
+ fiat_e.setText("")
+ else:
+ if edit is fiat_e:
+ btc_e.follows = True
+ btc_e.setAmount(int(amount / Decimal(rate) * COIN))
+ btc_e.setStyleSheet(ColorScheme.BLUE.as_stylesheet())
+ btc_e.follows = False
+ else:
+ fiat_e.follows = True
+ fiat_e.setText(self.fx.ccy_amount_str(
+ amount * Decimal(rate) / COIN, add_thousands_sep=False))
+ fiat_e.setStyleSheet(ColorScheme.BLUE.as_stylesheet())
+ fiat_e.follows = False
+
+ btc_e.follows = False
+ fiat_e.follows = False
+ fiat_e.textChanged.connect(partial(edit_changed, fiat_e))
+ btc_e.textChanged.connect(partial(edit_changed, btc_e))
+ fiat_e.is_last_edited = False
+
+ def update_status(self):
+ if not self.wallet:
+ return
+
+ network_text = ""
+ balance_text = ""
+
+ if self.tor_button:
+ self.tor_button.setVisible(self.network and bool(self.network.is_proxy_tor))
+
+ if self.network is None:
+ network_text = _("Offline")
+ icon = read_QIcon("status_disconnected.png")
+
+ elif self.network.is_connected():
+ server_height = self.network.get_server_height()
+ server_lag = self.network.get_local_height() - server_height
+ fork_str = "_fork" if len(self.network.get_blockchains())>1 else ""
+ # Server height can be 0 after switching to a new server
+ # until we get a headers subscription request response.
+ # Display the synchronizing message in that case.
+ if not self.wallet.is_up_to_date() or server_height == 0:
+ num_sent, num_answered = self.wallet.adb.get_history_sync_state_details()
+ network_text = ("{} ({}/{})"
+ .format(_("Synchronizing..."), num_answered, num_sent))
+ icon = read_QIcon("status_waiting.png")
+ elif server_lag > 1:
+ network_text = _("Server is lagging ({} blocks)").format(server_lag)
+ icon = read_QIcon("status_lagging%s.png"%fork_str)
+ else:
+ network_text = _("Connected")
+ p_bal = self.wallet.get_balances_for_piechart()
+ self.balance_label.update_list(
+ [
+ (_('Frozen'), COLOR_FROZEN, p_bal.frozen),
+ (_('Unmatured'), COLOR_UNMATURED, p_bal.unmatured),
+ (_('Unconfirmed'), COLOR_UNCONFIRMED, p_bal.unconfirmed),
+ (_('On-chain'), COLOR_CONFIRMED, p_bal.confirmed),
+ (_('Lightning'), COLOR_LIGHTNING, p_bal.lightning),
+ (_('Lightning frozen'), COLOR_FROZEN_LIGHTNING, p_bal.lightning_frozen),
+ ],
+ warning = self.wallet.is_low_reserve(),
+ )
+ balance = p_bal.total()
+ balance_text = _("Balance") + ": %s "%(self.format_amount_and_units(balance))
+ # append fiat balance and price
+ if self.fx.is_enabled():
+ balance_text += self.fx.get_fiat_status_text(balance,
+ self.base_unit(), self.get_decimal_point()) or ''
+ if not self.network.proxy or not self.network.proxy.enabled:
+ icon = read_QIcon("status_connected%s.png"%fork_str)
+ else:
+ icon = read_QIcon("status_connected_proxy%s.png"%fork_str)
+ else:
+ if self.network.proxy and self.network.proxy.enabled:
+ network_text = "{} ({})".format(_("Not connected"), _("proxy enabled"))
+ else:
+ network_text = _("Not connected")
+ icon = read_QIcon("status_disconnected.png")
+
+ if self.tray:
+ # note: don't include balance in systray tooltip, as some OSes persist tooltips,
+ # hence "leaking" the wallet balance (see #5665)
+ name_and_version = self.get_app_name_and_version_str()
+ self.tray.setToolTip(f"{name_and_version} ({network_text})")
+ self.balance_label.setText(balance_text or network_text)
+ if self.status_button:
+ self.status_button.setIcon(icon)
+
+ num_tasks = self.num_tasks()
+ if num_tasks == 0:
+ name = ''
+ elif num_tasks == 1:
+ with self._coroutines_scheduled_lock:
+ name = list(self._coroutines_scheduled.values())[0] + '...'
+ else:
+ name = f"{num_tasks} " + _('tasks') + '...'
+ self.tasks_label.setText(name)
+ self.tasks_label.setVisible(num_tasks > 0)
+
+ def num_tasks(self):
+ # For the moment, all the coroutines in this set are outgoing LN payments,
+ # so we can use this to disable buttons for rebalance/swap suggestions
+ return len(self._coroutines_scheduled)
+
+ def update_wallet(self):
+ self.update_status()
+ if self.wallet.is_up_to_date() or not self.network or not self.network.is_connected():
+ self.update_tabs()
+
+ def update_tabs(self, wallet=None):
+ if wallet is None:
+ wallet = self.wallet
+ if wallet != self.wallet:
+ return
+ self.history_model.refresh('update_tabs')
+ self.receive_tab.request_list.update()
+ self.receive_tab.update_current_request()
+ self.send_tab.invoice_list.update()
+ self.address_list.update()
+ self.utxo_list.update()
+ self.contact_list.update()
+ self.channels_list.update_rows.emit(wallet)
+ self.update_completions()
+
+ def refresh_tabs(self, wallet=None):
+ self.history_model.refresh('refresh_tabs')
+ self.receive_tab.request_list.refresh_all()
+ self.send_tab.invoice_list.refresh_all()
+ self.address_list.refresh_all()
+ self.utxo_list.refresh_all()
+ self.contact_list.refresh_all()
+ self.channels_list.update_rows.emit(self.wallet)
+
+ def create_channels_tab(self):
+ self.channels_list = ChannelsList(self)
+ tab = self.create_list_tab(self.channels_list)
+ tab.is_shown_cv = self.config.cv.GUI_QT_SHOW_TAB_CHANNELS
+ return tab
+
+ def create_history_tab(self):
+ self.history_model = HistoryModel(self)
+ self.history_list = l = HistoryList(self, self.history_model)
+ self.history_model.set_view(self.history_list)
+ l.searchable_list = l
+ tab = self.create_list_tab(self.history_list)
+ return tab
+
+ def show_address(self, addr: str, *, parent: QWidget = None):
+ from . import address_dialog
+ d = address_dialog.AddressDialog(self, addr, parent=parent)
+ d.exec()
+
+ def show_utxo(self, utxo):
+ from . import utxo_dialog
+ d = utxo_dialog.UTXODialog(self, utxo)
+ d.exec()
+
+ def show_channel_details(self, chan):
+ from .channel_details import ChannelDetailsDialog
+ ChannelDetailsDialog(self, chan).show()
+
+ def show_transaction(
+ self,
+ tx: Transaction,
+ *,
+ prompt_if_complete_unsaved: bool = True,
+ external_keypairs: Mapping[bytes, bytes] = None,
+ invoice: Invoice = None,
+ on_closed: Callable[[Optional[Transaction]], None] = None,
+ show_sign_button: bool = True,
+ show_broadcast_button: bool = True,
+ ):
+ show_transaction(
+ tx,
+ parent=self,
+ prompt_if_complete_unsaved=prompt_if_complete_unsaved,
+ external_keypairs=external_keypairs,
+ invoice=invoice,
+ on_closed=on_closed,
+ show_sign_button=show_sign_button,
+ show_broadcast_button=show_broadcast_button,
+ )
+
+ 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):
+ from .receive_tab import ReceiveTab
+ return ReceiveTab(self)
+
+ def do_copy(self, text: str, *, title: str = None) -> None:
+ self.gui_object.do_copy(text, title=title)
+
+ def show_tooltip_after_delay(self, message):
+ # tooltip cannot be displayed immediately when called from a menu; wait 200ms
+ QTimer.singleShot(200, lambda: QToolTip.showText(QCursor.pos(), message, self))
+
+ def toggle_qr_window(self):
+ from . import qrwindow
+ if not self.qr_window:
+ self.qr_window = qrwindow.QR_Window(self)
+ self.qr_window.setVisible(True)
+ self.qr_window_geometry = self.qr_window.geometry()
+ else:
+ if not self.qr_window.isVisible():
+ self.qr_window.setVisible(True)
+ self.qr_window.setGeometry(self.qr_window_geometry)
+ else:
+ self.qr_window_geometry = self.qr_window.geometry()
+ self.qr_window.setVisible(False)
+ self.receive_tab.update_receive_qr_window()
+
+ def show_send_tab(self):
+ self.tabs.setCurrentIndex(self.tabs.indexOf(self.send_tab))
+
+ def show_receive_tab(self):
+ self.tabs.setCurrentIndex(self.tabs.indexOf(self.receive_tab))
+
+ def create_send_tab(self):
+ from .send_tab import SendTab
+ return SendTab(self)
+
+ def get_contact_payto(self, key):
+ _type, label = self.contacts.get(key)
+ return label + ' <' + key + '>' if _type == 'address' else key
+
+ def update_completions(self):
+ l = [self.get_contact_payto(key) for key in self.contacts.keys()]
+ self.completions.setStringList(l)
+
+ @protected
+ def protect(self, func, args, password):
+ return func(*args, password)
+
+ def run_swap_dialog(
+ self,
+ *,
+ is_reverse: Optional[bool] = None,
+ recv_amount_sat_or_max: Optional[Union[int, str]] = None,
+ channels: Optional[Sequence['Channel']] = None,
+ get_coins: Optional[Callable[..., Sequence[PartialTxInput]]] = None,
+ ) -> bool:
+ if not self.network:
+ self.show_error(_("You are offline."))
+ return False
+ if not self.wallet.lnworker:
+ self.show_error(_('Lightning is disabled'))
+ return False
+ if not self.wallet.lnworker.num_sats_can_send() and not self.wallet.lnworker.num_sats_can_receive():
+ self.show_error(_("You do not have liquidity in your active channels."))
+ return False
+
+ transport = self.create_sm_transport()
+ if not transport:
+ return False
+
+ with transport:
+ if not self.initialize_swap_manager(transport):
+ return False
+ d = SwapDialog(
+ self,
+ transport,
+ is_reverse=is_reverse,
+ recv_amount_sat_or_max=recv_amount_sat_or_max,
+ channels=channels,
+ get_coins=get_coins
+ )
+ try:
+ return d.run(transport)
+ except InvalidSwapParameters as e:
+ self.show_error(str(e))
+ return False
+ except UserCancelled:
+ return False
+
+ def create_sm_transport(self) -> Optional['SwapServerTransport']:
+ sm = self.wallet.lnworker.swap_manager
+ if sm.is_server:
+ self.show_error(_('Swap server is active'))
+ return None
+
+ if self.network is None:
+ return None
+
+ if not self.config.SWAPSERVER_URL and not self.config.SWAPSERVER_NPUB:
+ if not self.question('\n'.join([
+ _('Electrum uses Nostr in order to find liquidity providers.'),
+ _('Do you want to enable Nostr?'),
+ ])):
+ return None
+
+ return sm.create_transport()
+
+ def initialize_swap_manager(self, transport: 'SwapServerTransport'):
+ sm = self.wallet.lnworker.swap_manager
+ if not sm.is_initialized.is_set():
+ async def wait_until_initialized():
+ timeout = transport.connect_timeout + 1
+ try:
+ await asyncio.wait_for(sm.is_initialized.wait(), timeout=timeout)
+ except asyncio.TimeoutError:
+ return
+ try:
+ self.run_coroutine_dialog(wait_until_initialized(), _('Please wait...'))
+ except UserCancelled:
+ return False
+ except Exception as e:
+ self.show_error(str(e))
+ return False
+
+ if not sm.is_initialized.is_set():
+ if not self.config.SWAPSERVER_URL:
+ if not self.choose_swapserver_dialog(transport):
+ return False
+ else:
+ self.show_error(f'Could not contact swap server at {self.config.SWAPSERVER_URL:}')
+ return False
+
+ assert sm.is_initialized.is_set()
+ return True
+
+ def choose_swapserver_dialog(self, transport: NostrTransport) -> bool:
+ assert isinstance(transport, NostrTransport)
+ if not transport.is_connected.is_set():
+ self.show_message(
+ '\n'.join([
+ _('Could not connect to a Nostr relay.'),
+ _('Please check your relays and network connection'),
+ ]))
+ return False
+ recent_offers = transport.get_recent_offers()
+ if not recent_offers:
+ self.show_message(
+ '\n'.join([
+ _('Could not find a swap provider.'),
+ ]))
+ return False
+ sm = self.wallet.lnworker.swap_manager
+ from .swap_dialog import SwapServerDialog
+ d = SwapServerDialog(self, recent_offers)
+ choice = d.run()
+ if choice is None:
+ return False
+ self.config.SWAPSERVER_NPUB = choice
+ offer = transport.get_offer(choice)
+ sm.update_pairs(offer.pairs)
+ return True
+
+ @qt_event_listener
+ def on_event_request_status(self, wallet, key, status):
+ if wallet != self.wallet:
+ return
+ req = self.wallet.get_request(key)
+ if req is None:
+ return
+ if status == PR_PAID:
+ # FIXME notification should only be shown if request was not PAID before
+ msg = _('Payment received')
+ amount = req.get_amount_sat()
+ if amount:
+ msg += ': ' + self.format_amount_and_units(amount)
+ msg += '\n' + req.get_message()
+ self.notify(msg)
+ self.receive_tab.request_list.delete_item(key)
+ self.receive_tab.do_clear()
+ self.need_update.set()
+ else:
+ self.receive_tab.request_list.refresh_item(key)
+
+ @qt_event_listener
+ def on_event_invoice_status(self, wallet, key, status):
+ if wallet != self.wallet:
+ return
+ if status == PR_PAID:
+ self.send_tab.invoice_list.delete_item(key)
+ else:
+ self.send_tab.invoice_list.refresh_item(key)
+
+ @qt_event_listener
+ def on_event_payment_succeeded(self, wallet, key):
+ # sent by lnworker, redundant with invoice_status
+ if wallet != self.wallet:
+ return
+ description = self.wallet.get_label_for_rhash(key)
+ self.notify(_('Payment sent') + '\n\n' + description)
+ self.need_update.set()
+
+ @qt_event_listener
+ def on_event_payment_failed(self, wallet, key, reason):
+ if wallet != self.wallet:
+ return
+ description = self.wallet.get_label_for_rhash(key)
+ self.notify(_('Payment failed') + '\n\n' + description + '\n\n' + reason)
+
+ def get_coins(self, **kwargs) -> Sequence[PartialTxInput]:
+ coins = self.get_manually_selected_coins()
+ if coins is not None:
+ return coins
+ else:
+ return self.wallet.get_spendable_coins(None, **kwargs)
+
+ def get_manually_selected_coins(self) -> Optional[Sequence[PartialTxInput]]:
+ """Return a list of selected coins or None.
+ Note: None means selection is not being used,
+ while an empty sequence means the user specifically selected that.
+ """
+ return self.utxo_list.get_spend_list()
+
+ def broadcast_or_show(self, tx: Transaction, *, invoice: 'Invoice' = None):
+ if not tx.is_complete():
+ self.show_transaction(tx, invoice=invoice)
+ return
+ if not self.network:
+ self.show_error(_("You can't broadcast a transaction without a live network connection."))
+ self.show_transaction(tx, invoice=invoice)
+ return
+ self.broadcast_transaction(tx, invoice=invoice)
+
+ def broadcast_transaction(self, tx: Transaction, *, invoice: Invoice = None):
+ self.send_tab.broadcast_transaction(tx, invoice=invoice)
+
+ @protected
+ def sign_tx(
+ self,
+ tx: PartialTransaction,
+ *,
+ callback,
+ external_keypairs: Optional[Mapping[bytes, bytes]],
+ password,
+ ):
+ self.sign_tx_with_password(tx, callback=callback, password=password, external_keypairs=external_keypairs)
+
+ def sign_tx_with_password(
+ self,
+ tx: PartialTransaction,
+ *,
+ callback,
+ password,
+ external_keypairs: Mapping[bytes, bytes] = None,
+ ):
+ '''Sign the transaction in a separate thread. When done, calls
+ the callback with a success code of True or False.
+ '''
+
+ def on_success(result):
+ callback(True)
+ def on_failure(exc_info):
+ self.on_error(exc_info)
+ callback(False)
+ on_success = run_hook('tc_sign_wrapper', self.wallet, tx, on_success, on_failure) or on_success
+ if external_keypairs:
+ # can sign directly
+ task = partial(tx.sign, external_keypairs)
+ else:
+ # ignore_warnings=True, because UI checks and asks user confirmation itself
+ task = partial(self.wallet.sign_transaction, tx, password, ignore_warnings=True)
+ msg = _('Signing transaction...')
+ WaitingDialog(self, msg, task, on_success, on_failure)
+
+ def mktx_for_open_channel(self, *, funding_sat, node_id, get_coins=None):
+ def make_tx(fee_policy, *, confirmed_only=False, base_tx=None):
+ assert base_tx is None
+ coins = get_coins() if get_coins else self.get_coins(nonlocal_only=True, confirmed_only=confirmed_only)
+ return self.wallet.lnworker.mktx_for_open_channel(
+ coins=coins,
+ funding_sat=funding_sat,
+ node_id=node_id,
+ fee_policy=fee_policy)
+ return make_tx
+
+ def open_channel(self, connect_str, funding_sat, *, push_amt=0, get_coins=None):
+ try:
+ node_id, rest = extract_nodeid(connect_str)
+ except ConnStringFormatError as e:
+ self.show_error(str(e))
+ return
+ if self.wallet.lnworker.has_conflicting_backup_with(node_id):
+ msg = messages.MSG_CONFLICTING_BACKUP_INSTANCE
+ if not self.question(msg):
+ return
+ # we need to know the fee before we broadcast, because the txid is required
+ make_tx = self.mktx_for_open_channel(funding_sat=funding_sat, node_id=node_id, get_coins=get_coins)
+ funding_tx, _, _ = self.confirm_tx_dialog(make_tx, funding_sat, context=TxEditorContext.CHANNEL_FUNDING)
+ if not funding_tx:
+ return
+ self._open_channel(connect_str, funding_sat, push_amt, funding_tx)
+
+ def confirm_tx_dialog(
+ self,
+ make_tx,
+ output_value, *,
+ payee_outputs: Optional[list[TxOutput]] = None,
+ context: TxEditorContext = TxEditorContext.PAYMENT,
+ batching_candidates: Sequence[Transaction] = None,
+ ) -> tuple[Optional[PartialTransaction], bool, bool]:
+ d = ConfirmTxDialog(
+ window=self,
+ make_tx=make_tx,
+ output_value=output_value,
+ payee_outputs=payee_outputs,
+ context=context,
+ batching_candidates=batching_candidates,
+ )
+ return d.run(), d.is_preview, d.did_swap
+
+ @protected
+ def _open_channel(self, connect_str, funding_sat, push_amt, funding_tx, password):
+ # read funding_sat from tx; converts '!' to int value
+ funding_sat = funding_tx.output_value_for_address(DummyAddress.CHANNEL)
+ def task():
+ return self.wallet.lnworker.open_channel(
+ connect_str=connect_str,
+ funding_tx=funding_tx,
+ funding_sat=funding_sat,
+ push_amt_sat=push_amt,
+ password=password)
+ def on_failure(exc_info):
+ type_, e, traceback = exc_info
+ #self.logger.error("Could not open channel", exc_info=exc_info)
+ self.show_error(_('Could not open channel: {}').format(repr(e)))
+ WaitingDialog(self, _('Opening channel...'), task, self.on_open_channel_success, on_failure)
+
+ def on_open_channel_success(self, args):
+ chan, funding_tx = args
+ lnworker = self.wallet.lnworker
+ if not chan.has_onchain_backup():
+ data = lnworker.export_channel_backup(chan.channel_id)
+ help_text = messages.MSG_CREATED_NON_RECOVERABLE_CHANNEL
+ help_text += '\n\n' + _('Alternatively, you can save a backup of your wallet file from the File menu')
+ self.show_qrcode(
+ data, _('Save channel backup'),
+ help_text=help_text,
+ show_copy_text_btn=True)
+ n = chan.constraints.funding_txn_minimum_depth
+ message = '\n'.join([
+ _('Channel established.'),
+ _('Remote peer ID') + ':' + chan.node_id.hex(),
+ _('This channel will be usable after {} confirmations').format(n)
+ ])
+ if not funding_tx.is_complete():
+ message += '\n\n' + _('Please sign and broadcast the funding transaction')
+ self.show_message(message)
+ self.show_transaction(funding_tx)
+ else:
+ self.show_message(message)
+
+ def handle_payment_identifier(self, text: str):
+ pi = PaymentIdentifier(self.wallet, text)
+ if pi.is_valid():
+ self.send_tab.set_payment_identifier(text)
+ else:
+ if pi.error:
+ self.show_error(str(pi.error))
+
+ def set_frozen_state_of_addresses(self, addrs, freeze: bool):
+ self.wallet.set_frozen_state_of_addresses(addrs, freeze)
+ self.address_list.refresh_all()
+ self.utxo_list.refresh_all()
+ self.address_list.selectionModel().clearSelection()
+
+ def set_frozen_state_of_coins(self, utxos: Sequence[PartialTxInput], freeze: bool):
+ utxos_str = {utxo.prevout.to_str() for utxo in utxos}
+ self.wallet.set_frozen_state_of_coins(utxos_str, freeze)
+ self.utxo_list.refresh_all()
+ self.utxo_list.selectionModel().clearSelection()
+
+ def create_list_tab(self, l):
+ w = QWidget()
+ w.searchable_list = l
+ vbox = QVBoxLayout()
+ w.setLayout(vbox)
+ #vbox.setContentsMargins(0, 0, 0, 0)
+ #vbox.setSpacing(0)
+ toolbar = l.create_toolbar(self.config)
+ if toolbar:
+ vbox.addLayout(toolbar)
+ vbox.addWidget(l)
+ if toolbar:
+ l.show_toolbar()
+ return w
+
+ def create_addresses_tab(self):
+ from .address_list import AddressList
+ self.address_list = AddressList(self)
+ tab = self.create_list_tab(self.address_list)
+ tab.is_shown_cv = self.config.cv.GUI_QT_SHOW_TAB_ADDRESSES
+ return tab
+
+ def create_utxo_tab(self):
+ from .utxo_list import UTXOList
+ self.utxo_list = UTXOList(self)
+ tab = self.create_list_tab(self.utxo_list)
+ tab.is_shown_cv = self.config.cv.GUI_QT_SHOW_TAB_UTXO
+ return tab
+
+ def create_contacts_tab(self):
+ from .contact_list import ContactList
+ self.contact_list = l = ContactList(self)
+ tab = self.create_list_tab(l)
+ tab.is_shown_cv = self.config.cv.GUI_QT_SHOW_TAB_CONTACTS
+ return tab
+
+ def remove_address(self, 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.receive_tab.do_clear()
+
+ def payto_contacts(self, labels):
+ self.send_tab.payto_contacts(labels)
+
+ def set_contact(self, label, address):
+ if not (is_address(address) or is_valid_email(address)): # email = lightning address
+ self.show_error(_('Invalid Address'))
+ self.contact_list.update() # Displays original unchanged value
+ return False
+ address_type = 'address' if is_address(address) else 'lnaddress'
+ self.contacts[address] = (address_type, label)
+ self.contact_list.update()
+ self.history_list.update()
+ self.update_completions()
+ return True
+
+ def delete_contacts(self, labels):
+ if not self.question(_("Remove {} from your list of contacts?")
+ .format(" + ".join(labels))):
+ return
+ for label in labels:
+ self.contacts.pop(label)
+ self.history_list.update()
+ self.contact_list.update()
+ self.update_completions()
+
+ def show_onchain_invoice(self, invoice: Invoice):
+ amount_str = self.format_amount(invoice.get_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)
+ buttons = Buttons(CloseButton(d))
+ vbox.addLayout(grid)
+ vbox.addLayout(buttons)
+ d.exec()
+
+ def show_lightning_invoice(self, invoice: Invoice):
+ from electrum.util import format_short_id
+ lnaddr = decode_bolt11_invoice(invoice.lightning_invoice)
+ d = WindowModalDialog(self, _("Lightning Invoice"))
+ vbox = QVBoxLayout(d)
+ grid = QGridLayout()
+ pubkey_e = ShowQRLineEdit(lnaddr.pubkey.serialize().hex(), self.config, title=_("Public Key"))
+ pubkey_e.setMinimumWidth(700)
+ grid.addWidget(QLabel(_("Public Key") + ':'), 0, 0)
+ grid.addWidget(pubkey_e, 0, 1)
+ grid.addWidget(QLabel(_("Amount") + ':'), 1, 0)
+ amount_str = self.format_amount(invoice.get_amount_sat()) + ' ' + self.base_unit()
+ grid.addWidget(QLabel(amount_str), 1, 1)
+ grid.addWidget(QLabel(_("Description") + ':'), 2, 0)
+ grid.addWidget(QLabel(invoice.message), 2, 1)
+ grid.addWidget(QLabel(_("Creation time") + ':'), 3, 0)
+ grid.addWidget(QLabel(format_time(invoice.time)), 3, 1)
+ if invoice.exp:
+ grid.addWidget(QLabel(_("Expiration time") + ':'), 4, 0)
+ grid.addWidget(QLabel(format_time(invoice.time + invoice.exp)), 4, 1)
+ grid.addWidget(QLabel(_('Features') + ':'), 5, 0)
+ grid.addWidget(QLabel(', '.join(lnaddr.get_features().get_names())), 5, 1)
+ payhash_e = ShowQRLineEdit(lnaddr.paymenthash.hex(), self.config, title=_("Payment Hash"))
+ grid.addWidget(QLabel(_("Payment Hash") + ':'), 6, 0)
+ grid.addWidget(payhash_e, 6, 1)
+ fallback = lnaddr.get_fallback_address()
+ if fallback:
+ fallback_e = ShowQRLineEdit(fallback, self.config, title=_("Fallback address"))
+ grid.addWidget(QLabel(_("Fallback address") + ':'), 7, 0)
+ grid.addWidget(fallback_e, 7, 1)
+ invoice_e = ShowQRTextEdit(config=self.config)
+ invoice_e.setFont(QFont(MONOSPACE_FONT))
+ invoice_e.addCopyButton()
+ invoice_e.setText(invoice.lightning_invoice)
+ grid.addWidget(QLabel(_('Text') + ':'), 8, 0)
+ grid.addWidget(invoice_e, 8, 1)
+ r_tags = lnaddr.get_routing_info('r')
+ r_tags = '\n'.join(repr(r) for r in BOLT11Addr.format_bolt11_routing_info_as_human_readable(r_tags))
+ routing_e = QTextEdit(str(r_tags))
+ routing_e.setReadOnly(True)
+ grid.addWidget(QLabel(_("Routing Hints") + ':'), 9, 0)
+ grid.addWidget(routing_e, 9, 1)
+ vbox.addLayout(grid)
+ vbox.addLayout(Buttons(CloseButton(d),))
+ d.exec()
+
+ def create_console_tab(self):
+ from .console import Console
+ self.console = console = Console()
+ console.is_shown_cv = self.config.cv.GUI_QT_SHOW_TAB_CONSOLE
+ return console
+
+ def create_notes_tab(self):
+ from PyQt6 import QtGui, QtWidgets
+ notes_tab = QtWidgets.QPlainTextEdit()
+ notes_tab.setWordWrapMode(QtGui.QTextOption.WrapMode.WrapAnywhere)
+ notes_tab.setFont(QtGui.QFont(MONOSPACE_FONT, 10, QtGui.QFont.Weight.Normal))
+ notes_tab.setPlainText(self.wallet.db.get('notes_text', ''))
+ notes_tab.is_shown_cv = self.config.cv.GUI_QT_SHOW_TAB_NOTES
+ notes_tab.textChanged.connect(self.maybe_save_notes_text)
+ return notes_tab
+
+ @rate_limited(10, ts_after=True)
+ def maybe_save_notes_text(self):
+ self.save_notes_text()
+
+ def save_notes_text(self):
+ self.logger.info('saving notes')
+ self.wallet.db.put('notes_text', self.notes_tab.toPlainText())
+
+ def update_console(self):
+ console = self.console
+ console.history = self.wallet.db.get_stored_item("qt-console-history", [])
+ console.history_index = len(console.history)
+
+ console.updateNamespace({
+ 'wallet': self.wallet,
+ 'network': self.network,
+ 'plugins': self.gui_object.plugins,
+ 'window': self,
+ 'config': self.config,
+ 'electrum': electrum,
+ 'daemon': self.gui_object.daemon,
+ 'util': util,
+ 'bitcoin': bitcoin,
+ 'lnutil': lnutil,
+ 'channels': list(self.wallet.lnworker.channels.values()) if self.wallet.lnworker else [],
+ 'scan_qr': scan_qr_from_screenshot,
+ })
+
+ c = commands.Commands(
+ config=self.config,
+ daemon=self.gui_object.daemon,
+ network=self.network,
+ callback=lambda: self.console.set_json(True))
+ methods = {}
+ def mkfunc(f, method):
+ return lambda *args, **kwargs: f(method,
+ args,
+ self.password_dialog,
+ **{**kwargs, 'wallet': self.wallet})
+ for m in dir(c):
+ if m[0]=='_' or m in ['network','wallet','config','daemon']: continue
+ methods[m] = mkfunc(c._run, m)
+
+ console.updateNamespace(methods)
+
+ def show_balance_dialog(self):
+ balance = self.wallet.get_balances_for_piechart().total()
+ if balance == 0 and not self.balance_label.has_warning:
+ return
+ from .balance_dialog import BalanceDialog
+ d = BalanceDialog(self, wallet=self.wallet)
+ d.run()
+
+ def create_status_bar(self):
+ sb = QStatusBar()
+ self.balance_label = BalanceToolButton()
+ self.balance_label.setText("Loading wallet...")
+ self.balance_label.setAutoRaise(True)
+ self.balance_label.clicked.connect(self.show_balance_dialog)
+ sb.addWidget(self.balance_label)
+
+ font_height = QFontMetrics(self.balance_label.font()).height()
+ sb_height = max(35, int(2 * font_height))
+ sb.setFixedHeight(sb_height)
+
+ # remove border of all items in status bar
+ self.setStyleSheet("QStatusBar::item { border: 0px;} ")
+
+ self.search_box = QLineEdit()
+ self.search_box.textChanged.connect(self.do_search)
+ self.search_box.hide()
+ sb.addPermanentWidget(self.search_box)
+
+ self.update_check_button = QPushButton("")
+ self.update_check_button.setFlat(True)
+ self.update_check_button.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
+ self.update_check_button.setIcon(read_QIcon("update.png"))
+ self.update_check_button.hide()
+ sb.addPermanentWidget(self.update_check_button)
+
+ self.password_required_button = QPushButton(_('Password required'))
+ self.password_required_button.setFlat(True)
+ self.password_required_button.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
+ self.password_required_button.setIcon(read_QIcon("warning.png"))
+ self.password_required_button.setIconSize(self.password_required_button.iconSize() * 1.3)
+ self.password_required_button.clicked.connect(self.on_password_required_button_clicked)
+ self.password_required_button.hide()
+ sb.addPermanentWidget(self.password_required_button)
+
+ self.tasks_label = QLabel('')
+ sb.addPermanentWidget(self.tasks_label)
+
+ self.password_button = StatusBarButton(QIcon(), _("Password"), self.change_password_dialog, sb_height)
+ sb.addPermanentWidget(self.password_button)
+
+ sb.addPermanentWidget(StatusBarButton(read_QIcon("preferences.png"), _("Preferences"), self.settings_dialog, sb_height))
+ self.seed_button = StatusBarButton(read_QIcon("seed.png"), _("Seed"), self.show_seed_dialog, sb_height)
+ sb.addPermanentWidget(self.seed_button)
+ self.lightning_button = StatusBarButton(read_QIcon("lightning.png"), _("Lightning Network"), self.gui_object.show_lightning_dialog, sb_height)
+ sb.addPermanentWidget(self.lightning_button)
+ self.update_lightning_icon()
+ self.status_button = None
+ self.tor_button = None
+ if self.network:
+ self.tor_button = StatusBarButton(
+ read_QIcon("tor_logo.png"),
+ _("Tor"),
+ partial(self.gui_object.show_network_dialog, proxy_tab=True),
+ sb_height,
+ )
+ sb.addPermanentWidget(self.tor_button)
+ self.tor_button.setVisible(False)
+ # add status btn last, to place it at rightmost pos
+ self.status_button = StatusBarButton(
+ read_QIcon("status_disconnected.png"),
+ _("Network"),
+ self.gui_object.show_network_dialog,
+ sb_height,
+ )
+ sb.addPermanentWidget(self.status_button)
+ # add plugins
+ run_hook('create_status_bar', sb)
+ self.setStatusBar(sb)
+
+ def create_coincontrol_statusbar(self):
+ self.coincontrol_sb = sb = QStatusBar()
+ sb.setSizeGripEnabled(False)
+ #sb.setFixedHeight(3 * char_width_in_lineedit())
+ sb.setStyleSheet('QStatusBar::item {border: None;} '
+ + ColorScheme.GREEN.as_stylesheet(True))
+
+ self.coincontrol_label = QLabel()
+ self.coincontrol_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
+ self.coincontrol_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
+ sb.addWidget(self.coincontrol_label)
+
+ clear_cc_button = EnterButton(_('Reset'), lambda: self.utxo_list.clear_coincontrol())
+ clear_cc_button.setStyleSheet("margin-right: 5px;")
+ sb.addPermanentWidget(clear_cc_button)
+
+ sb.setVisible(False)
+ return sb
+
+ def set_coincontrol_msg(self, msg: Optional[str]) -> None:
+ if not msg:
+ self.coincontrol_label.setText("")
+ self.coincontrol_sb.setVisible(False)
+ return
+ self.coincontrol_label.setText(msg)
+ self.coincontrol_sb.setVisible(True)
+
+ def update_lightning_icon(self):
+ if not self.wallet.has_lightning():
+ self.lightning_button.setVisible(False)
+ return
+ if self.network is None or self.network.channel_db is None:
+ self.lightning_button.setVisible(False)
+ return
+ self.lightning_button.setVisible(True)
+
+ cur, total, progress_percent = self.network.lngossip.get_sync_progress_estimate()
+ # self.logger.debug(f"updating lngossip sync progress estimate: cur={cur}, total={total}")
+ progress_str = "??%"
+ if progress_percent is not None:
+ progress_str = f"{progress_percent}%"
+ if progress_percent and progress_percent >= 100:
+ self.lightning_button.setMaximumWidth(25)
+ self.lightning_button.setText('')
+ self.lightning_button.setToolTip(_("The Lightning Network graph is fully synced."))
+ else:
+ self.lightning_button.setMaximumWidth(25 + 6 * char_width_in_lineedit())
+ self.lightning_button.setText(progress_str)
+ self.lightning_button.setToolTip(_("The Lightning Network graph is syncing...\n"
+ "Payments are more likely to succeed with a more complete graph."))
+
+ def update_lock_icon(self):
+ icon = read_QIcon("lock.png") if self.wallet.has_password() and (self.wallet.get_unlocked_password() is None) else read_QIcon("unlock.png")
+ self.password_button.setIcon(icon)
+
+ def update_buttons_on_seed(self):
+ self.seed_button.setVisible(self.wallet.has_seed())
+ self.password_button.setVisible(self.wallet.may_have_password())
+
+ def change_password_dialog(self):
+ from electrum.stored_dict import StorageEncryptionVersion
+ if StorageEncryptionVersion.XPUB_PASSWORD in self.wallet.get_available_storage_encryption_versions():
+ from .password_dialog import ChangePasswordDialogForHW
+ d = ChangePasswordDialogForHW(self, self.wallet)
+ ok, old_password, new_password, encrypt_with_xpub = d.run()
+ if not ok:
+ return
+ has_xpub_encryption = self.wallet.storage.get_encryption_version() == StorageEncryptionVersion.XPUB_PASSWORD
+ def on_password(hw_dev_pw):
+ self._update_wallet_password(
+ old_password = hw_dev_pw if has_xpub_encryption else old_password,
+ new_password = hw_dev_pw if encrypt_with_xpub else new_password,
+ xpub_encrypt=encrypt_with_xpub,
+ )
+ self.thread.add(
+ self.wallet.keystore.get_password_for_storage_encryption,
+ on_success=on_password)
+ else:
+ from .password_dialog import ChangePasswordDialogForSW
+ d = ChangePasswordDialogForSW(self, self.wallet)
+ ok, old_password, new_password, encrypt_file = d.run()
+ if not ok:
+ return
+ self._update_wallet_password(
+ old_password=old_password, new_password=new_password)
+ self.update_lock_menu()
+
+ def _update_wallet_password(self, *, old_password, new_password, xpub_encrypt=False):
+ try:
+ self.wallet.update_password(old_password, new_password, encrypt_storage=True, xpub_encrypt=xpub_encrypt)
+ except InvalidPassword as e:
+ self.show_error(str(e))
+ return
+ except BaseException:
+ self.logger.exception('Failed to update password')
+ self.show_error(_('Failed to update password'))
+ return
+ msg = _('Password was updated successfully') if self.wallet.has_password() else _('Password is disabled, this wallet is not protected')
+ self.show_message(msg, title=_("Success"))
+ self.update_lock_icon()
+
+ def toggle_search(self):
+ self.search_box.setHidden(not self.search_box.isHidden())
+ if not self.search_box.isHidden():
+ self.search_box.setFocus()
+ else:
+ self.do_search('')
+
+ def do_search(self, t):
+ tab = self.tabs.currentWidget()
+ if hasattr(tab, 'searchable_list'):
+ tab.searchable_list.filter(t)
+
+ def new_channel_dialog(self, *, amount_sat=None, min_amount_sat=None):
+ from electrum.lnutil import MIN_FUNDING_SAT
+ from .new_channel_dialog import NewChannelDialog
+ assert self.wallet.can_have_lightning()
+ confirmed = self.wallet.get_spendable_balance_sat(confirmed_only=True)
+ min_amount_sat = min_amount_sat or MIN_FUNDING_SAT
+ if confirmed < min_amount_sat:
+ msg = _('Not enough funds') + '\n\n' + _('You need at least {} to open a channel.').format(self.format_amount_and_units(min_amount_sat))
+ self.show_error(msg)
+ return
+ if not self.wallet.has_lightning() and not self.init_lightning_dialog():
+ return
+ lnworker = self.wallet.lnworker
+ if not lnworker.channels and not lnworker.channel_backups:
+ msg = _('Do you want to create your first channel?') + '\n\n' + messages.MSG_LIGHTNING_WARNING
+ if not self.question(msg):
+ return
+ d = NewChannelDialog(self, amount_sat=amount_sat, min_amount_sat=min_amount_sat)
+ return d.run()
+
+ def new_contact_dialog(self):
+ d = WindowModalDialog(self, _("New Contact"))
+ vbox = QVBoxLayout(d)
+ vbox.addWidget(QLabel(_('New Contact') + ':'))
+ grid = QGridLayout()
+ line1 = QLineEdit()
+ line1.setFixedWidth(32 * char_width_in_lineedit())
+ line2 = QLineEdit()
+ line2.setFixedWidth(32 * char_width_in_lineedit())
+ address_label = QLabel(_("Address"))
+ address_label.setToolTip(_("Bitcoin- or Lightning address"))
+ grid.addWidget(address_label, 1, 0)
+ grid.addWidget(line1, 1, 1)
+ grid.addWidget(QLabel(_("Name")), 2, 0)
+ grid.addWidget(line2, 2, 1)
+ vbox.addLayout(grid)
+ vbox.addLayout(Buttons(CancelButton(d), OkButton(d)))
+ if d.exec():
+ self.set_contact(line2.text(), line1.text())
+
+ def init_lightning_dialog(self, close_dialog: Optional[QDialog] = None) -> bool:
+ assert not self.wallet.has_lightning()
+ if self.wallet.can_have_deterministic_lightning():
+ msg = _(
+ "Lightning is not enabled because this wallet was created with an old version of Electrum. "
+ "Create lightning keys?")
+ else:
+ msg = _(
+ "Warning: this wallet type does not support channel recovery from seed. "
+ "You will need to backup your wallet every time you create a new channel. "
+ "Create lightning keys?")
+ if self.question(msg):
+ self._init_lightning_dialog(close_dialog=close_dialog)
+ return self.wallet.has_lightning()
+
+ @protected
+ def _init_lightning_dialog(self, *, close_dialog: Optional[QDialog], password):
+ if close_dialog is not None:
+ close_dialog.close()
+ self.wallet.init_lightning(password=password)
+ self.update_lightning_icon()
+ self.show_message(_('Lightning keys have been initialized.'))
+
+ def show_wallet_info(self):
+ from .wallet_info_dialog import WalletInfoDialog
+ d = WalletInfoDialog(self, window=self)
+ d.exec()
+
+ def remove_wallet(self):
+ if self.question('\n'.join([
+ _('Delete wallet file?'),
+ "%s"%self.wallet.storage.get_path(),
+ _('If your wallet contains funds, make sure you have saved its seed.')])):
+ self._delete_wallet()
+
+ @protected
+ def _delete_wallet(self, password):
+ wallet_path = self.wallet.storage.get_path()
+ basename = os.path.basename(wallet_path)
+ r = self.gui_object.daemon.delete_wallet(wallet_path)
+ self.close()
+ if r:
+ self.show_error(_("Wallet removed: {}").format(basename))
+ else:
+ self.show_error(_("Wallet file not found: {}").format(basename))
+
+ @protected
+ def get_password(self, password, message=None):
+ # may be used by plugins to get password
+ return password
+
+ @protected
+ def show_seed_dialog(self, password):
+ if not self.wallet.has_seed():
+ self.show_message(_('This wallet has no seed'))
+ return
+ keystore = self.wallet.get_keystore()
+ try:
+ seed = keystore.get_seed(password)
+ passphrase = keystore.get_passphrase(password)
+ except BaseException as e:
+ self.show_error(repr(e))
+ return
+ from .seed_dialog import SeedDialog
+ d = SeedDialog(self, seed, passphrase, config=self.config)
+ d.exec()
+
+ def show_qrcode(self, data, title=None, parent=None, *,
+ help_text=None, show_copy_text_btn=False):
+ if not data:
+ return
+ if title is None:
+ title = _("QR code")
+ d = QRDialog(
+ data=data,
+ parent=parent or self,
+ title=title,
+ help_text=help_text,
+ show_copy_text_btn=show_copy_text_btn,
+ config=self.config,
+ )
+ d.exec()
+
+ @protected
+ def show_private_key(self, address, password):
+ if not address:
+ return
+ try:
+ pk = self.wallet.export_private_key(address, password)
+ except Exception as e:
+ self.logger.exception('')
+ self.show_message(repr(e))
+ return
+ xtype = bitcoin.deserialize_privkey(pk)[0]
+ d = WindowModalDialog(self, _("Private key"))
+ d.setMinimumSize(600, 150)
+ vbox = QVBoxLayout()
+ vbox.addWidget(QLabel(_("Address") + ': ' + address))
+ vbox.addWidget(QLabel(_("Script type") + ': ' + xtype))
+ vbox.addWidget(QLabel(_("Private key") + ':'))
+ keys_e = ShowQRTextEdit(text=pk, config=self.config)
+ keys_e.addCopyButton()
+ vbox.addWidget(keys_e)
+ vbox.addLayout(Buttons(CloseButton(d)))
+ d.setLayout(vbox)
+ d.exec()
+
+ msg_sign = _("Signing with an address actually means signing with the corresponding "
+ "private key, and verifying with the corresponding public key. The "
+ "address you have entered does not have a unique public key, so these "
+ "operations cannot be performed.") + '\n\n' + \
+ _('The operation is undefined. Not just in Electrum, but in general.')
+
+ @protected
+ def do_sign(self, address, message, signature, password):
+ address = address.text().strip()
+ message = message.toPlainText().strip()
+ if not bitcoin.is_address(address):
+ self.show_message(_('Invalid Bitcoin address.'))
+ return
+ if self.wallet.is_watching_only():
+ self.show_message(_('This is a watching-only wallet.'))
+ return
+ if not self.wallet.is_mine(address):
+ self.show_message(_('Address not in wallet.'))
+ return
+ txin_type = self.wallet.get_txin_type(address)
+ if txin_type not in ['p2pkh', 'p2wpkh', 'p2wpkh-p2sh']:
+ self.show_message(_('Cannot sign messages with this type of address:') + \
+ ' ' + txin_type + '\n\n' + self.msg_sign)
+ return
+ task = partial(self.wallet.sign_message, address, message, password)
+
+ def show_signed_message(sig):
+ try:
+ signature.setText(base64.b64encode(sig).decode('ascii'))
+ except RuntimeError:
+ # (signature) wrapped C/C++ object has been deleted
+ pass
+
+ self.thread.add(task, on_success=show_signed_message)
+
+ def do_verify(self, address, message, signature):
+ address = address.text().strip()
+ message = message.toPlainText().strip().encode('utf-8')
+ if not bitcoin.is_address(address):
+ self.show_message(_('Invalid Bitcoin address.'))
+ return
+ try:
+ # This can throw on invalid base64
+ sig = base64.b64decode(str(signature.toPlainText()), validate=True)
+ verified = bitcoin.verify_usermessage_with_address(address, sig, message)
+ except Exception as e:
+ verified = False
+ if verified:
+ self.show_message(_("Signature verified"))
+ else:
+ self.show_error(_("Wrong signature"))
+
+ def sign_verify_message(self, address=''):
+ d = WindowModalDialog(self, _('Sign/verify Message'))
+ d.setMinimumSize(610, 290)
+
+ layout = QGridLayout(d)
+
+ message_e = QTextEdit()
+ message_e.setAcceptRichText(False)
+ layout.addWidget(QLabel(_('Message')), 1, 0)
+ layout.addWidget(message_e, 1, 1)
+ layout.setRowStretch(2,3)
+
+ address_e = QLineEdit()
+ address_e.setText(address)
+ layout.addWidget(QLabel(_('Address')), 2, 0)
+ layout.addWidget(address_e, 2, 1)
+
+ signature_e = ScanShowQRTextEdit(config=self.config)
+ layout.addWidget(QLabel(_('Signature')), 3, 0)
+ layout.addWidget(signature_e, 3, 1)
+ layout.setRowStretch(3,1)
+
+ hbox = QHBoxLayout()
+
+ b = QPushButton(_("Sign"))
+ b.clicked.connect(lambda: self.do_sign(address_e, message_e, signature_e))
+ hbox.addWidget(b)
+
+ b = QPushButton(_("Verify"))
+ b.clicked.connect(lambda: self.do_verify(address_e, message_e, signature_e))
+ hbox.addWidget(b)
+
+ b = QPushButton(_("Close"))
+ b.clicked.connect(d.accept)
+ hbox.addWidget(b)
+ layout.addLayout(hbox, 4, 1)
+ d.exec()
+
+ @protected
+ def do_decrypt(self, message_e, pubkey_e, encrypted_e, password):
+ if self.wallet.is_watching_only():
+ self.show_message(_('This is a watching-only wallet.'))
+ return
+ ciphertext = encrypted_e.toPlainText()
+ task = partial(self.wallet.decrypt_message, pubkey_e.text(), ciphertext, password)
+
+ def setText(text):
+ try:
+ message_e.setText(text.decode('utf-8'))
+ except RuntimeError:
+ # (message_e) wrapped C/C++ object has been deleted
+ pass
+
+ self.thread.add(task, on_success=setText)
+
+ def do_encrypt(self, message_e, pubkey_e, encrypted_e):
+ from electrum import crypto
+ message = message_e.toPlainText()
+ message = message.encode('utf-8')
+ try:
+ public_key = ecc.ECPubkey(bfh(pubkey_e.text()))
+ except BaseException as e:
+ self.logger.exception('Invalid Public key')
+ self.show_warning(_('Invalid Public key'))
+ return
+ encrypted = crypto.ecies_encrypt_message(public_key, message)
+ encrypted_e.setText(encrypted.decode('ascii'))
+
+ def encrypt_message(self, address=''):
+ d = WindowModalDialog(self, _('Encrypt/decrypt Message'))
+ d.setMinimumSize(610, 490)
+
+ layout = QGridLayout(d)
+
+ message_e = QTextEdit()
+ message_e.setAcceptRichText(False)
+ layout.addWidget(QLabel(_('Message')), 1, 0)
+ layout.addWidget(message_e, 1, 1)
+ layout.setRowStretch(2,3)
+
+ pubkey_e = QLineEdit()
+ if address:
+ pubkey = self.wallet.get_public_key(address)
+ pubkey_e.setText(pubkey)
+ layout.addWidget(QLabel(_('Public key')), 2, 0)
+ layout.addWidget(pubkey_e, 2, 1)
+
+ encrypted_e = QTextEdit()
+ encrypted_e.setAcceptRichText(False)
+ layout.addWidget(QLabel(_('Encrypted')), 3, 0)
+ layout.addWidget(encrypted_e, 3, 1)
+ layout.setRowStretch(3,1)
+
+ hbox = QHBoxLayout()
+ b = QPushButton(_("Encrypt"))
+ b.clicked.connect(lambda: self.do_encrypt(message_e, pubkey_e, encrypted_e))
+ hbox.addWidget(b)
+
+ b = QPushButton(_("Decrypt"))
+ b.clicked.connect(lambda: self.do_decrypt(message_e, pubkey_e, encrypted_e))
+ hbox.addWidget(b)
+
+ b = QPushButton(_("Close"))
+ b.clicked.connect(d.accept)
+ hbox.addWidget(b)
+
+ layout.addLayout(hbox, 4, 1)
+ d.exec()
+
+ def tx_from_text(self, data: Union[str, bytes]) -> Union[None, 'PartialTransaction', 'Transaction']:
+ from electrum.transaction import tx_from_any
+ try:
+ return tx_from_any(data)
+ except BaseException as e:
+ self.show_critical(_("Electrum was unable to parse your transaction") + ":\n" + repr(e))
+ return
+
+ def import_channel_backup(self, encrypted: str):
+ if not self.question('Import channel backup?'):
+ return
+ if not self.wallet.lnworker:
+ self.show_error(_('Lightning is disabled'))
+ return
+ try:
+ self.wallet.lnworker.import_channel_backup(encrypted)
+ except Exception as e:
+ self.show_error("failed to import backup" + '\n' + str(e))
+ return
+
+ def read_tx_from_qrcode(self):
+ def cb(success: bool, error: str, data):
+ if not success:
+ if error:
+ self.show_error(error)
+ return
+ if not data:
+ return
+ # if the user scanned a bitcoin URI
+ if data.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'):
+ self.handle_payment_identifier(data)
+ return
+ if data.lower().startswith('channel_backup:'):
+ self.import_channel_backup(data)
+ return
+ # else if the user scanned an offline signed tx
+ tx = self.tx_from_text(data)
+ if not tx:
+ return
+ self.show_transaction(tx)
+
+ scan_qrcode_from_camera(parent=self.top_level_window(), config=self.config, callback=cb)
+
+ def read_tx_from_file(self) -> Optional[Transaction]:
+ fileName = getOpenFileName(
+ parent=self,
+ title=_("Select your transaction file"),
+ filter=TRANSACTION_FILE_EXTENSION_FILTER_ANY,
+ config=self.config,
+ )
+ if not fileName:
+ return
+ file_content = None # type: None | str | bytes
+ # 1. try to open file as "text"
+ try:
+ with open(fileName, "r", encoding="ascii") as f:
+ file_content = f.read() # type: str
+ except (ValueError, IOError, os.error) as reason:
+ pass
+ else:
+ assert isinstance(file_content, str), f"expected str, got {type(file_content)}"
+ file_content = file_content.strip() # for text, we can safely strip leading/trailing whitespaces
+ # 2. try to open file as "binary"
+ if file_content is None:
+ try:
+ with open(fileName, "rb") as f:
+ file_content = f.read() # type: bytes
+ except (ValueError, IOError, os.error) as reason:
+ self.show_critical(_("Electrum was unable to open your transaction file") + "\n" + str(reason),
+ title=_("Unable to read file or no transaction found"))
+ if file_content is None:
+ return None
+ return self.tx_from_text(file_content)
+
+ def do_process_from_text(self):
+ text = text_dialog(
+ parent=self,
+ title=_('Input raw transaction'),
+ header_layout=_("Transaction:"),
+ ok_label=_("Load transaction"),
+ config=self.config,
+ )
+ if not text:
+ return
+ tx = self.tx_from_text(text)
+ if tx:
+ self.show_transaction(tx)
+
+ def do_process_from_text_channel_backup(self):
+ text = text_dialog(
+ parent=self,
+ title=_('Input channel backup'),
+ header_layout=_("Channel Backup:"),
+ ok_label=_("Load backup"),
+ config=self.config,
+ )
+ if not text:
+ return
+ if text.startswith('channel_backup:'):
+ self.import_channel_backup(text)
+
+ def do_process_from_file(self):
+ tx = self.read_tx_from_file()
+ if tx:
+ self.show_transaction(tx)
+
+ def do_process_from_txid(self, *, parent: QWidget = None, txid: str = None):
+ if parent is None:
+ parent = self
+ from electrum import transaction
+ if txid is None:
+ txid, ok = QInputDialog.getText(parent, _('Lookup transaction'), _('Transaction ID') + ':')
+ if not ok:
+ txid = None
+ if not txid:
+ return
+ txid = str(txid).strip()
+ tx = self.wallet.adb.get_transaction(txid)
+ if tx is None:
+ raw_tx = self._fetch_tx_from_network(txid, parent=parent)
+ if not raw_tx:
+ return
+ tx = transaction.Transaction(raw_tx)
+ self.show_transaction(tx)
+
+ def _fetch_tx_from_network(self, txid: str, *, parent: QWidget = None) -> Optional[str]:
+ if not self.network:
+ self.show_message(_("You are offline."), parent=parent)
+ return
+ try:
+ raw_tx = self.network.run_from_another_thread(
+ self.network.get_transaction(txid, timeout=10))
+ except UntrustedServerReturnedError as e:
+ self.logger.info(f"Error getting transaction from network: {repr(e)}")
+ self.show_message(
+ _("Error getting transaction from network") + ":\n" + e.get_message_for_gui(),
+ parent=parent,
+ )
+ return
+ except Exception as e:
+ self.show_message(
+ _("Error getting transaction from network") + ":\n" + repr(e),
+ parent=parent,
+ )
+ return
+ return raw_tx
+
+ @protected
+ def export_privkeys_dialog(self, password):
+ if self.wallet.is_watching_only():
+ self.show_message(_("This is a watching-only wallet"))
+ return
+
+ if isinstance(self.wallet, Multisig_Wallet):
+ self.show_message(_('WARNING: This is a multi-signature wallet.') + '\n' +
+ _('It cannot be "backed up" by simply exporting these private keys.'))
+
+ d = WindowModalDialog(self, _('Private keys'))
+ d.setMinimumSize(980, 300)
+ vbox = QVBoxLayout(d)
+
+ msg = "%s\n%s\n%s" % (_("WARNING: ALL your private keys are secret."),
+ _("Exposing a single private key can compromise your entire wallet!"),
+ _("In particular, DO NOT use 'redeem private key' services proposed by third parties."))
+ vbox.addWidget(QLabel(msg))
+
+ e = QTextEdit()
+ e.setReadOnly(True)
+ vbox.addWidget(e)
+
+ defaultname = f'electrum-private-keys-{self.wallet.basename()}.csv'
+ select_msg = _('Select file to export your private keys to')
+ hbox, filename_e, csv_button = filename_field(self, self.config, defaultname, select_msg)
+ vbox.addLayout(hbox)
+
+ b = OkButton(d, _('Export'))
+ b.setEnabled(False)
+ vbox.addLayout(Buttons(CancelButton(d), b))
+
+ private_keys = {}
+ addresses = self.wallet.get_addresses()
+ done = False
+ cancelled = False
+ def privkeys_thread():
+ for addr in addresses:
+ time.sleep(0.1)
+ if done or cancelled:
+ break
+ privkey = self.wallet.export_private_key(addr, password)
+ private_keys[addr] = privkey
+ self.computing_privkeys_signal.emit()
+ if not cancelled:
+ self.computing_privkeys_signal.disconnect()
+ self.show_privkeys_signal.emit()
+
+ def show_privkeys():
+ s = "\n".join(map(lambda x: x[0] + "\t"+ x[1], private_keys.items()))
+ e.setText(s)
+ b.setEnabled(True)
+ self.show_privkeys_signal.disconnect()
+ nonlocal done
+ done = True
+
+ def on_dialog_closed(*args):
+ nonlocal cancelled
+ if not done:
+ cancelled = True
+ self.computing_privkeys_signal.disconnect()
+ self.show_privkeys_signal.disconnect()
+
+ self.computing_privkeys_signal.connect(lambda: e.setText("Please wait... %d/%d"%(len(private_keys),len(addresses))))
+ self.show_privkeys_signal.connect(show_privkeys)
+ d.finished.connect(on_dialog_closed)
+ threading.Thread(target=privkeys_thread).start()
+
+ if not d.exec():
+ done = True
+ return
+
+ filename = filename_e.text()
+ if not filename:
+ return
+
+ try:
+ self.do_export_privkeys(filename, private_keys, csv_button.isChecked())
+ except (IOError, os.error) as reason:
+ txt = "\n".join([
+ _("Electrum was unable to produce a private key-export."),
+ str(reason)
+ ])
+ self.show_critical(txt, title=_("Unable to create csv"))
+
+ except Exception as e:
+ self.show_message(repr(e))
+ return
+
+ self.show_message(_("Private keys exported."))
+
+ def do_export_privkeys(self, fileName, pklist, is_csv):
+ with open(fileName, "w+") as f:
+ os_chmod(fileName, 0o600) # set restrictive perms *before* we write data
+ if is_csv:
+ transaction = csv.writer(f)
+ transaction.writerow(["address", "private_key"])
+ for addr, pk in pklist.items():
+ transaction.writerow(["%34s"%addr,pk])
+ else:
+ f.write(json.dumps(pklist, indent = 4))
+
+ def do_import_labels(self):
+ def on_import():
+ self.need_update.set()
+ import_meta_gui(self, _('labels'), self.wallet.import_labels, on_import)
+
+ def do_export_labels(self):
+ export_meta_gui(self, _('labels'), self.wallet.export_labels)
+
+ def import_invoices(self):
+ import_meta_gui(self, _('invoices'), self.wallet.import_invoices, self.send_tab.invoice_list.update)
+
+ def export_invoices(self):
+ export_meta_gui(self, _('invoices'), self.wallet.export_invoices)
+
+ def import_requests(self):
+ import_meta_gui(self, _('requests'), self.wallet.import_requests, self.receive_tab.request_list.update)
+
+ def export_requests(self):
+ export_meta_gui(self, _('requests'), self.wallet.export_requests)
+
+ def import_contacts(self):
+ import_meta_gui(self, _('contacts'), self.contacts.import_file, self.contact_list.update)
+
+ def export_contacts(self):
+ export_meta_gui(self, _('contacts'), self.contacts.export_file)
+
+
+ def sweep_key_dialog(self):
+ if not self.network:
+ self.show_error(_("You are offline."))
+ return
+ d = WindowModalDialog(self, title=_('Sweep private keys'))
+ d.setMinimumSize(600, 300)
+ vbox = QVBoxLayout(d)
+ hbox_top = QHBoxLayout()
+ hbox_top.addWidget(QLabel(_("Enter private keys to sweep coins from:")))
+ hbox_top.addWidget(InfoButton(WIF_HELP_TEXT), alignment=Qt.AlignmentFlag.AlignRight)
+ vbox.addLayout(hbox_top)
+ keys_e = ScanQRTextEdit(allow_multi=True, config=self.config)
+ keys_e.setTabChangesFocus(True)
+ vbox.addWidget(keys_e)
+ vbox.addWidget(QLabel(_("Send to address") + ":"))
+
+ addresses = self.wallet.get_unused_addresses()
+ if not addresses:
+ addresses = self.wallet.get_receiving_addresses()
+ h, address_e = address_field(addresses)
+ vbox.addLayout(h)
+
+ vbox.addStretch(1)
+ button = OkButton(d, _('Sweep'))
+ vbox.addLayout(Buttons(CancelButton(d), button))
+ button.setEnabled(False)
+
+ def get_address():
+ addr = str(address_e.text()).strip()
+ if bitcoin.is_address(addr):
+ return addr
+
+ def get_pk(*, raise_on_error=False) -> Sequence[str]:
+ text = str(keys_e.toPlainText())
+ return keystore.get_private_keys(text, raise_on_error=raise_on_error)
+
+ def on_edit():
+ valid_privkeys = False
+ try:
+ valid_privkeys = bool(get_pk(raise_on_error=True))
+ except Exception as e:
+ button.setToolTip(f'{_("Error")}: {repr(e)}')
+ else:
+ button.setToolTip('')
+ button.setEnabled(get_address() is not None and valid_privkeys)
+ on_address = lambda text: address_e.setStyleSheet((ColorScheme.DEFAULT if get_address() else ColorScheme.RED).as_stylesheet())
+ keys_e.textChanged.connect(on_edit)
+ address_e.textChanged.connect(on_edit)
+ address_e.textChanged.connect(on_address)
+ on_address(str(address_e.text()))
+ if not d.exec():
+ return
+ # user pressed "sweep"
+ addr = get_address()
+ try:
+ self.wallet.check_address_for_corruption(addr)
+ except InternalAddressCorruption as e:
+ self.show_error(str(e))
+ raise
+ privkeys = get_pk()
+
+ def on_success(result):
+ coins, keypairs = result
+ outputs = [PartialTxOutput.from_address_and_value(addr, value='!')]
+ self.warn_if_watching_only()
+ self.send_tab.pay_onchain_dialog(
+ outputs, external_keypairs=keypairs, get_coins=lambda *args, **kwargs: coins)
+ def on_failure(exc_info):
+ self.on_error(exc_info)
+ msg = _('Preparing sweep transaction...')
+ task = lambda: self.network.run_from_another_thread(
+ sweep_preparations(privkeys, self.network))
+ WaitingDialog(self, msg, task, on_success, on_failure)
+
+ def _do_import(self, title, header_layout, func):
+ text = text_dialog(
+ parent=self,
+ title=title,
+ header_layout=header_layout,
+ ok_label=_('Import'),
+ allow_multi=True,
+ config=self.config,
+ )
+ if not text:
+ return
+ keys = str(text).split()
+ good_inputs, bad_inputs = func(keys)
+ if good_inputs:
+ msg = '\n'.join(good_inputs[:10])
+ if len(good_inputs) > 10: msg += '\n...'
+ self.show_message(_("The following addresses were added")
+ + f' ({len(good_inputs)}):\n' + msg)
+ if bad_inputs:
+ msg = "\n".join(f"{key[:10]}... ({msg})" for key, msg in bad_inputs[:10])
+ if len(bad_inputs) > 10: msg += '\n...'
+ self.show_error(_("The following inputs could not be imported")
+ + f' ({len(bad_inputs)}):\n' + msg)
+ self.address_list.update()
+ self.history_list.update()
+
+ def import_addresses(self):
+ if not self.wallet.can_import_address():
+ return
+ title, msg = _('Import addresses'), _("Enter addresses")+':'
+ self._do_import(title, msg, self.wallet.import_addresses)
+
+ @protected
+ def do_import_privkey(self, password):
+ if not self.wallet.can_import_privkey():
+ return
+ title = _('Import private keys')
+ header_layout = QHBoxLayout()
+ header_layout.addWidget(QLabel(_("Enter private keys")+':'))
+ header_layout.addWidget(InfoButton(WIF_HELP_TEXT), alignment=Qt.AlignmentFlag.AlignRight)
+ self._do_import(title, header_layout, lambda x: self.wallet.import_private_keys(x, password))
+
+ def refresh_amount_edits(self):
+ edits = self.send_tab.amount_e, self.receive_tab.receive_amount_e
+ amounts = [edit.get_amount() for edit in edits]
+ for edit, amount in zip(edits, amounts):
+ edit.setAmount(amount)
+
+ def update_fiat(self):
+ b = self.fx and self.fx.is_enabled()
+ self.send_tab.fiat_send_e.setVisible(b)
+ self.receive_tab.fiat_receive_e.setVisible(b)
+ self.history_model.refresh('update_fiat')
+ self.history_list.update_toolbar_menu()
+ self.address_list.refresh_headers()
+ self.address_list.update()
+ self.update_status()
+
+ def settings_dialog(self):
+ from .settings_dialog import SettingsDialog
+ d = SettingsDialog(self, self.config)
+ d.exec()
+ if self.fx:
+ self.fx.trigger_update()
+ run_hook('close_settings_dialog')
+ if d.need_restart:
+ self.show_warning(_('Please restart Electrum to activate the new GUI settings'), title=_('Success'))
+ else:
+ # Some values might need to be updated if settings have changed.
+ # For example 'Can send' in the lightning tab will change if the fees config is changed.
+ self.refresh_tabs()
+
+ def _show_closing_warnings(self) -> bool:
+ """Show any closing warnings and return True if the user chose to quit anyway."""
+
+ warnings: Set[str] = set()
+ for cb in self.closing_warning_callbacks:
+ if warning := cb():
+ warnings.add(warning)
+
+ for warning in list(warnings)[:3]:
+ warning = ''.join([
+ _("Are you sure you want to close Electrum?"),
+ '\n\n',
+ _("An ongoing operation requires you to stay online."),
+ '\n',
+ warning
+ ])
+ result = self.question(
+ msg=warning,
+ icon=QMessageBox.Icon.Warning,
+ title=_("Warning"),
+ )
+ if not result:
+ break
+ else:
+ # user chose to cancel all warnings or there were no warnings
+ return True
+ return False
+
+ def register_closing_warning_callback(self, callback: Callable[[], Optional[str]]) -> None:
+ """
+ Registers a callback that will be called when the wallet is closed. If the callback
+ returns a string it will be shown to the user as a warning to prevent them closing the wallet.
+ """
+ assert not inspect.iscoroutinefunction(callback)
+ def warning_callback() -> Optional[str]:
+ try:
+ return callback()
+ except Exception:
+ self.logger.exception("Error in closing warning callback")
+ return None
+ self.logger.debug(f"registering wallet closing warning callback")
+ self.closing_warning_callbacks.append(warning_callback)
+
+ def _check_ongoing_force_closures(self) -> Optional[str]:
+ from electrum.lnutil import MIN_FINAL_CLTV_DELTA_ACCEPTED
+ if not self.wallet.has_lightning():
+ return None
+ if not self.network:
+ return None
+ force_closes = self.wallet.lnworker.lnwatcher.get_pending_force_closes()
+ if not force_closes:
+ return
+ # fixme: this is inaccurate, we need local_height - cltv_of_htlc
+ cltv_delta = MIN_FINAL_CLTV_DELTA_ACCEPTED
+ msg = '\n\n'.join([
+ _("Pending channel force-close"),
+ messages.MSG_FORCE_CLOSE_WARNING.format(cltv_delta),
+ ])
+ return msg
+
+ def _check_ongoing_submarine_swaps_callback(self) -> Optional[str]:
+ """Callback that will return a warning string if there are unconfirmed swap funding txs."""
+ from electrum.submarine_swaps import MIN_FINAL_CLTV_DELTA_FOR_CLIENT, LOCKTIME_DELTA_REFUND
+ if not (self.wallet.has_lightning() and self.wallet.lnworker.swap_manager):
+ return None
+ if not self.network:
+ return None
+ ongoing_swaps = self.wallet.lnworker.swap_manager.get_pending_swaps()
+ if not ongoing_swaps:
+ return None
+ is_forward = any(not swap.is_reverse for swap in ongoing_swaps)
+ if is_forward:
+ # fixme: this is inaccurate, we need local_height - cltv_of_htlc
+ delta = MIN_FINAL_CLTV_DELTA_FOR_CLIENT
+ warning = messages.MSG_FORWARD_SWAP_WARNING.format(delta)
+ else:
+ locktime = min(swap.locktime for swap in ongoing_swaps)
+ delta = locktime - self.wallet.adb.get_local_height()
+ warning = messages.MSG_REVERSE_SWAP_WARNING.format(delta)
+ return "\n\n".join((
+ _("Pending submarine swap"),
+ warning,
+ ))
+
+ def closeEvent(self, event):
+ # note that closeEvent is NOT called if the user quits with Ctrl-C
+ if not self._show_closing_warnings():
+ event.ignore()
+ return
+ self.clean_up()
+ event.accept()
+
+ def clean_up(self):
+ if self._cleaned_up:
+ return
+ self._cleaned_up = True
+ if self.thread:
+ self.thread.stop()
+ self.thread = None
+ with self._coroutines_scheduled_lock:
+ coro_keys = list(self._coroutines_scheduled.keys())
+ for fut in coro_keys:
+ fut.cancel()
+ self.wallet.txbatcher.set_password_future(None)
+ self.unregister_callbacks()
+ self.config.GUI_QT_WINDOW_IS_MAXIMIZED = self.isMaximized()
+ self.save_notes_text()
+ if not self.isMaximized():
+ g = self.geometry()
+ self.wallet.db.put(
+ "winpos-qt", [g.left(),g.top(), g.width(),g.height()])
+ if self.qr_window:
+ self.qr_window.close()
+ self.close_wallet()
+
+ if self._update_check_thread:
+ self._update_check_thread.stop()
+ if self.tray:
+ self.tray = None
+ self.timer.stop()
+ self.gui_object.close_window(self)
+
+ def cpfp_dialog(self, parent_tx: Transaction) -> None:
+ new_tx = self.wallet.cpfp(parent_tx, 0)
+ total_size = parent_tx.estimated_size() + new_tx.estimated_size()
+ parent_txid = parent_tx.txid()
+ assert parent_txid
+ parent_fee = self.wallet.get_tx_info(parent_tx).fee
+ if parent_fee is None:
+ self.show_error(_("Can't CPFP: unknown fee for parent transaction."))
+ return
+ d = WindowModalDialog(self, _('Child Pays for Parent'))
+ vbox = QVBoxLayout(d)
+ msg = _(
+ "A CPFP is a transaction that sends an unconfirmed output back to "
+ "yourself, with a high fee. The goal is to have miners confirm "
+ "the parent transaction in order to get the fee attached to the "
+ "child transaction.")
+ vbox.addWidget(WWLabel(msg))
+ msg2 = _("The proposed fee is computed using your "
+ "fee/kB settings, applied to the total size of both child and "
+ "parent transactions. After you broadcast a CPFP transaction, "
+ "it is normal to see a new unconfirmed transaction in your history.")
+ vbox.addWidget(WWLabel(msg2))
+ grid = QGridLayout()
+ grid.addWidget(QLabel(_('Total size') + ':'), 0, 0)
+ grid.addWidget(QLabel(f"{total_size} {UI_UNIT_NAME_TXSIZE_VBYTES}"), 0, 1)
+ max_fee = new_tx.output_value()
+ grid.addWidget(QLabel(_('Input amount') + ':'), 1, 0)
+ grid.addWidget(QLabel(self.format_amount(max_fee) + ' ' + self.base_unit()), 1, 1)
+ output_amount = QLabel('')
+ grid.addWidget(QLabel(_('Output amount') + ':'), 2, 0)
+ grid.addWidget(output_amount, 2, 1)
+ fee_e = BTCAmountEdit(self.get_decimal_point)
+ combined_fee = QLabel('')
+ combined_feerate = QLabel('')
+ def on_fee_edit(x):
+ fee_for_child = fee_e.get_amount()
+ if fee_for_child is None:
+ return
+ out_amt = max_fee - fee_for_child
+ out_amt_str = (self.format_amount(out_amt) + ' ' + self.base_unit()) if out_amt else ''
+ output_amount.setText(out_amt_str)
+ comb_fee = parent_fee + fee_for_child
+ comb_fee_str = (self.format_amount(comb_fee) + ' ' + self.base_unit()) if comb_fee else ''
+ combined_fee.setText(comb_fee_str)
+ comb_feerate = comb_fee / total_size * 1000
+ comb_feerate_str = self.format_fee_rate(comb_feerate) if comb_feerate else ''
+ combined_feerate.setText(comb_feerate_str)
+ fee_e.textChanged.connect(on_fee_edit)
+ def get_child_fee_from_total_feerate(fee_per_kb: Optional[int]) -> Optional[int]:
+ if fee_per_kb is None:
+ return None
+ package_fee = FeePolicy.estimate_fee_for_feerate(fee_per_kb=fee_per_kb, size=total_size)
+ child_fee = package_fee - parent_fee
+ child_fee = min(max_fee, child_fee)
+ # pay at least minrelayfee for combined size:
+ min_child_fee = FeePolicy.estimate_fee_for_feerate(fee_per_kb=self.wallet.relayfee(), size=total_size)
+ child_fee = max(min_child_fee, child_fee)
+ return child_fee
+ fee_policy = FeePolicy(self.config.FEE_POLICY)
+ suggested_feerate = fee_policy.fee_per_kb(self.network)
+ fee = get_child_fee_from_total_feerate(suggested_feerate)
+ fee_e.setAmount(fee)
+ grid.addWidget(QLabel(_('Fee for child') + ':'), 3, 0)
+ grid.addWidget(fee_e, 3, 1)
+ def on_rate(fee_rate):
+ fee = get_child_fee_from_total_feerate(fee_rate)
+ fee_e.setAmount(fee)
+ fee_slider = FeeSlider(parent=self, network=self.network, fee_policy=fee_policy, callback=on_rate)
+ fee_combo = FeeComboBox(fee_slider)
+ fee_slider.update()
+ grid.addWidget(fee_slider, 4, 1)
+ grid.addWidget(fee_combo, 4, 2)
+ grid.addWidget(QLabel(_('Total fee') + ':'), 5, 0)
+ grid.addWidget(combined_fee, 5, 1)
+ grid.addWidget(QLabel(_('Total feerate') + ':'), 6, 0)
+ grid.addWidget(combined_feerate, 6, 1)
+ vbox.addLayout(grid)
+ vbox.addLayout(Buttons(CancelButton(d), OkButton(d)))
+ if not d.exec():
+ return
+ fee = fee_e.get_amount()
+ if fee is None:
+ return # fee left empty, treat it as "cancel"
+ if fee > max_fee:
+ self.show_error(_('Max fee exceeded'))
+ return
+ try:
+ new_tx = self.wallet.cpfp(parent_tx, fee)
+ except CannotCPFP as e:
+ self.show_error(str(e))
+ return
+ self.show_transaction(new_tx)
+
+ def bump_fee_dialog(self, tx: Transaction):
+ if not isinstance(tx, PartialTransaction):
+ tx = PartialTransaction.from_tx(tx)
+ if not tx.add_info_from_wallet_and_network(wallet=self.wallet, show_error=self.show_error):
+ return
+ d = BumpFeeDialog(main_window=self, tx=tx)
+ d.run()
+
+ def dscancel_dialog(self, tx: Transaction):
+ if not isinstance(tx, PartialTransaction):
+ tx = PartialTransaction.from_tx(tx)
+ if not tx.add_info_from_wallet_and_network(wallet=self.wallet, show_error=self.show_error):
+ return
+ d = DSCancelDialog(main_window=self, tx=tx)
+ d.run()
+
+ def save_transaction_into_wallet(self, tx: Transaction):
+ win = self.top_level_window()
+ try:
+ if not self.wallet.adb.add_transaction(tx):
+ win.show_error(_("Transaction could not be saved.") + "\n" +
+ _("It conflicts with current history."))
+ return False
+ except AddTransactionException as e:
+ win.show_error(e)
+ return False
+ else:
+ self.wallet.save_db()
+ # need to update at least: history_list, utxo_list, address_list
+ self.need_update.set()
+ msg = (_("Transaction added to wallet history.") + '\n\n' +
+ _("Note: this is an offline transaction, if you want the network "
+ "to see it, you need to broadcast it."))
+ win.msg_box(QPixmap(icon_path("offline_tx.png")), None, _('Success'), msg)
+ return True
+
+ def show_cert_mismatch_error(self):
+ if self.showing_cert_mismatch_error:
+ return
+ self.showing_cert_mismatch_error = True
+ self.show_critical(title=_("Certificate mismatch"),
+ msg=_("The SSL certificate provided by the main server did not match the fingerprint passed in with the --serverfingerprint option.") + "\n\n" +
+ _("Electrum will now exit."))
+ self.showing_cert_mismatch_error = False
+ self.close()
+
+ def rebalance_dialog(self, chan1, chan2, amount_sat=None):
+ from .rebalance_dialog import RebalanceDialog
+ if chan1 is None or chan2 is None:
+ return
+ d = RebalanceDialog(self, chan1, chan2, amount_sat)
+ d.run()
+
+ def on_swap_result(self, txid: Optional[str], *, is_reverse: bool):
+ msg = _("Submarine swap") + ': ' + (_("Success") if txid else _("Expired")) + '\n\n'
+ if txid:
+ msg += _("Funding transaction") + ': ' + txid + '\n\n'
+ if is_reverse:
+ msg += messages.MSG_REVERSE_SWAP_FUNDING_MEMPOOL
+ else:
+ msg += messages.MSG_FORWARD_SWAP_FUNDING_MEMPOOL
+ self.show_message_signal.emit(msg)
+ else:
+ msg += _("Lightning funds were not received.") # FIXME should this not depend on is_reverse?
+ self.show_error_signal.emit(msg)
+
+ def set_payment_identifier(self, pi: str):
+ # delegate to send_tab
+ self.send_tab.set_payment_identifier(pi)
diff --git a/electrum/gui/qt/my_treeview.py b/electrum/gui/qt/my_treeview.py
new file mode 100644
index 000000000000..4998da0b7623
--- /dev/null
+++ b/electrum/gui/qt/my_treeview.py
@@ -0,0 +1,530 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2023 The Electrum Developers
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import enum
+from decimal import Decimal
+from typing import (Optional, TYPE_CHECKING, Union, List, Dict, Any,
+ Sequence, Iterable, Type, Callable)
+
+from PyQt6.QtGui import (QStandardItem, QStandardItemModel,
+ QShowEvent, QPainter, QHelpEvent, QMouseEvent, QAction)
+from PyQt6.QtCore import (Qt, QPersistentModelIndex, QModelIndex, QItemSelectionModel,
+ QSortFilterProxyModel, QSize, QAbstractItemModel, QEvent, QPoint)
+from PyQt6.QtWidgets import (QLabel, QHBoxLayout, QAbstractItemView, QLineEdit,
+ QWidget, QToolButton, QTreeView, QHeaderView, QStyledItemDelegate,
+ QMenu, QStyleOptionViewItem)
+
+from electrum.i18n import _
+from electrum.simple_config import ConfigVarWithConfig
+
+from electrum.gui import messages
+
+from .util import read_QIcon
+
+if TYPE_CHECKING:
+ from electrum import SimpleConfig
+ from .main_window import ElectrumWindow
+
+
+class QMenuWithConfig(QMenu):
+
+ def __init__(self, config: 'SimpleConfig'):
+ QMenu.__init__(self)
+ self.setToolTipsVisible(True)
+ self.config = config
+
+ def addToggle(
+ self,
+ text: str,
+ callback: Callable[[], None],
+ *,
+ tooltip: Optional[str] = None,
+ default_state: bool = False,
+ ) -> QAction:
+ m = self.addAction(text, callback)
+ m.setCheckable(True)
+ m.setChecked(default_state)
+ tooltip = tooltip or ""
+ m.setToolTip(tooltip)
+ return m
+
+ def addConfig(
+ self,
+ configvar: 'ConfigVarWithConfig',
+ *,
+ callback: Optional[Callable[[], None]] = None,
+ checked: Optional[bool] = None, # to override initial state of checkbox
+ short_desc: Optional[str] = None,
+ ) -> QAction:
+ assert isinstance(configvar, ConfigVarWithConfig), configvar
+ if short_desc is None:
+ short_desc = configvar.get_short_desc()
+ assert short_desc is not None, f"short_desc missing for {configvar}"
+ if checked is None:
+ checked = bool(configvar.get())
+ tooltip = None
+ if (long_desc := configvar.get_long_desc()) is not None:
+ tooltip = messages.to_rtf(long_desc)
+ return self.addToggle(
+ short_desc,
+ lambda: self._do_toggle_config(configvar, callback=callback),
+ tooltip=tooltip,
+ default_state=checked,
+ )
+
+ def _do_toggle_config(
+ self,
+ configvar: 'ConfigVarWithConfig',
+ *,
+ callback: Optional[Callable[[], None]] = None,
+ ):
+ b = configvar.get()
+ configvar.set(not b)
+ # call cb after configvar state is updated:
+ if callback:
+ callback()
+
+
+def create_toolbar_with_menu(config: 'SimpleConfig', title):
+ menu = QMenuWithConfig(config)
+ toolbar_button = QToolButton()
+ toolbar_button.setText(_('Tools'))
+ toolbar_button.setIcon(read_QIcon("preferences.png"))
+ toolbar_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
+ toolbar_button.setMenu(menu)
+ toolbar_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
+ toolbar_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
+ toolbar = QHBoxLayout()
+ toolbar.addWidget(QLabel(title))
+ toolbar.addStretch()
+ toolbar.addWidget(toolbar_button)
+ return toolbar, menu
+
+
+class MySortModel(QSortFilterProxyModel):
+ def __init__(self, parent, *, sort_role):
+ super().__init__(parent)
+ self._sort_role = sort_role
+
+ def lessThan(self, source_left: QModelIndex, source_right: QModelIndex):
+ parent_model = self.sourceModel() # type: QStandardItemModel
+ item1 = parent_model.itemFromIndex(source_left)
+ item2 = parent_model.itemFromIndex(source_right)
+ data1 = item1.data(self._sort_role)
+ data2 = item2.data(self._sort_role)
+ if data1 is not None and data2 is not None:
+ return data1 < data2
+ v1 = item1.text()
+ v2 = item2.text()
+ try:
+ return Decimal(v1) < Decimal(v2)
+ except Exception:
+ return v1 < v2
+
+
+class ElectrumItemDelegate(QStyledItemDelegate):
+ def __init__(self, tv: 'MyTreeView'):
+ super().__init__(tv)
+ self.tv = tv
+ self.opened = None
+
+ def on_closeEditor(editor: QLineEdit, hint):
+ self.opened = None
+ self.tv.is_editor_open = False
+ if self.tv._pending_update:
+ self.tv.update()
+
+ def on_commitData(editor: QLineEdit):
+ new_text = editor.text()
+ idx = QModelIndex(self.opened)
+ row, col = idx.row(), idx.column()
+ edit_key = self.tv.get_edit_key_from_coordinate(row, col)
+ assert edit_key is not None, (idx.row(), idx.column())
+ self.tv.on_edited(idx, edit_key=edit_key, text=new_text)
+
+ self.closeEditor.connect(on_closeEditor)
+ self.commitData.connect(on_commitData)
+
+ def createEditor(self, parent, option, idx):
+ self.opened = QPersistentModelIndex(idx)
+ self.tv.is_editor_open = True
+ return super().createEditor(parent, option, idx)
+
+ def paint(self, painter: QPainter, option: QStyleOptionViewItem, idx: QModelIndex) -> None:
+ custom_data = idx.data(MyTreeView.ROLE_CUSTOM_PAINT)
+ if custom_data is None:
+ return super().paint(painter, option, idx)
+ else:
+ # let's call the default paint method first; to paint the background (e.g. selection)
+ super().paint(painter, option, idx)
+ # and now paint on top of that
+ custom_data.paint(painter, option.rect)
+
+ def helpEvent(self, evt: QHelpEvent, view: QAbstractItemView, option: QStyleOptionViewItem, idx: QModelIndex) -> bool:
+ custom_data = idx.data(MyTreeView.ROLE_CUSTOM_PAINT)
+ if custom_data is None:
+ return super().helpEvent(evt, view, option, idx)
+ else:
+ if evt.type() == QEvent.Type.ToolTip:
+ if custom_data.show_tooltip(evt):
+ return True
+ return super().helpEvent(evt, view, option, idx)
+
+ def sizeHint(self, option: QStyleOptionViewItem, idx: QModelIndex) -> QSize:
+ custom_data = idx.data(MyTreeView.ROLE_CUSTOM_PAINT)
+ if custom_data is None:
+ return super().sizeHint(option, idx)
+ else:
+ default_size = super().sizeHint(option, idx)
+ return custom_data.sizeHint(default_size)
+
+
+class MyTreeView(QTreeView):
+
+ ROLE_CLIPBOARD_DATA = Qt.ItemDataRole.UserRole + 100
+ ROLE_CUSTOM_PAINT = Qt.ItemDataRole.UserRole + 101
+ ROLE_EDIT_KEY = Qt.ItemDataRole.UserRole + 102
+ ROLE_FILTER_DATA = Qt.ItemDataRole.UserRole + 103
+
+ filter_columns: Iterable[int]
+
+ class BaseColumnsEnum(enum.IntEnum):
+ @staticmethod
+ def _generate_next_value_(name: str, start: int, count: int, last_values):
+ # this is overridden to get a 0-based counter
+ return count
+
+ Columns: Type[BaseColumnsEnum]
+
+ def __init__(
+ self,
+ *,
+ parent: Optional[QWidget] = None,
+ main_window: Optional['ElectrumWindow'] = None,
+ stretch_column: Optional[int] = None,
+ editable_columns: Optional[Sequence[int]] = None,
+ ):
+ parent = parent or main_window
+ super().__init__(parent)
+ self.main_window = main_window
+ self.config = self.main_window.config if self.main_window else None
+ self.stretch_column = stretch_column
+ self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
+ self.customContextMenuRequested.connect(self.create_menu)
+ self.setUniformRowHeights(True)
+
+ # Control which columns are editable
+ if editable_columns is None:
+ editable_columns = []
+ self.editable_columns = set(editable_columns)
+ self.setItemDelegate(ElectrumItemDelegate(self))
+ self.current_filter = ""
+ self.is_editor_open = False
+
+ self.setRootIsDecorated(False) # remove left margin
+ self.toolbar_shown = False
+
+ # When figuring out the size of columns, Qt by default looks at
+ # the first 1000 rows (at least if resize mode is QHeaderView.ResizeToContents).
+ # This would be REALLY SLOW, and it's not perfect anyway.
+ # So to speed the UI up considerably, set it to
+ # only look at as many rows as currently visible.
+ self.header().setResizeContentsPrecision(0)
+
+ self._pending_update = False
+ self._forced_update = False
+
+ self._currently_open_menu = None # type: Optional[QMenu]
+
+ self._default_bg_brush = QStandardItem().background()
+ self.proxy = None # history, and address tabs use a proxy
+
+ def create_menu(self, position: QPoint) -> None:
+ pass
+
+ def open_menu(self, menu: QMenu, position) -> None:
+ try:
+ self._currently_open_menu = menu
+ menu.exec(self.viewport().mapToGlobal(position))
+ finally:
+ self._currently_open_menu = None
+
+ def close_menu(self):
+ if self._currently_open_menu:
+ self._currently_open_menu.close()
+ self._currently_open_menu = None
+
+ def set_editability(self, items):
+ for idx, i in enumerate(items):
+ i.setEditable(idx in self.editable_columns)
+
+ def selected_in_column(self, column: int):
+ items = self.selectionModel().selectedIndexes()
+ return list(x for x in items if x.column() == column)
+
+ def get_role_data_for_current_item(self, *, col, role) -> Any:
+ idx = self.selectionModel().currentIndex()
+ idx = idx.sibling(idx.row(), col)
+ item = self.item_from_index(idx)
+ if item:
+ return item.data(role)
+
+ def item_from_index(self, idx: QModelIndex) -> Optional[QStandardItem]:
+ model = self.model()
+ if isinstance(model, QSortFilterProxyModel):
+ idx = model.mapToSource(idx)
+ return model.sourceModel().itemFromIndex(idx)
+ else:
+ return model.itemFromIndex(idx)
+
+ def original_model(self) -> QAbstractItemModel:
+ model = self.model()
+ if isinstance(model, QSortFilterProxyModel):
+ return model.sourceModel()
+ else:
+ return model
+
+ def set_current_idx(self, set_current: QPersistentModelIndex):
+ if set_current:
+ assert isinstance(set_current, QPersistentModelIndex)
+ assert set_current.isValid()
+ self.selectionModel().select(QModelIndex(set_current), QItemSelectionModel.SelectionFlag.SelectCurrent)
+
+ def update_headers(self, headers: Union[List[str], Dict[int, str]]):
+ # headers is either a list of column names, or a dict: (col_idx->col_name)
+ if not isinstance(headers, dict): # convert to dict
+ headers = dict(enumerate(headers))
+ col_names = [headers[col_idx] for col_idx in sorted(headers.keys())]
+ self.original_model().setHorizontalHeaderLabels(col_names)
+ self.header().setStretchLastSection(False)
+ for col_idx in headers:
+ sm = QHeaderView.ResizeMode.Stretch if col_idx == self.stretch_column else QHeaderView.ResizeMode.ResizeToContents
+ self.header().setSectionResizeMode(col_idx, sm)
+
+ def keyPressEvent(self, event):
+ if self.itemDelegate().opened:
+ return
+ if event.key() in [Qt.Key.Key_F2, Qt.Key.Key_Return, Qt.Key.Key_Enter]:
+ self.on_activated(self.selectionModel().currentIndex())
+ return
+ super().keyPressEvent(event)
+
+ def mouseDoubleClickEvent(self, event: QMouseEvent):
+ idx: QModelIndex = self.indexAt(event.pos())
+ if self.proxy:
+ idx = self.proxy.mapToSource(idx)
+ if not idx.isValid():
+ # can happen e.g. before list is populated for the first time
+ return
+ self.on_double_click(idx)
+
+ def on_double_click(self, idx):
+ pass
+
+ def on_activated(self, idx):
+ # on 'enter' we show the menu
+ pt = self.visualRect(idx).bottomLeft()
+ pt.setX(50)
+ self.customContextMenuRequested.emit(pt)
+
+ def edit(self, idx, trigger=QAbstractItemView.EditTrigger.AllEditTriggers, event=None):
+ """
+ this is to prevent:
+ edit: editing failed
+ from inside qt
+ """
+ return super().edit(idx, trigger, event)
+
+ def on_edited(self, idx: QModelIndex, edit_key, *, text: str) -> None:
+ raise NotImplementedError()
+
+ def should_hide(self, row):
+ """
+ row_num is for self.model(). So if there is a proxy, it is the row number
+ in that!
+ """
+ return False
+
+ def get_text_from_coordinate(self, row, col) -> str:
+ idx = self.model().index(row, col)
+ item = self.item_from_index(idx)
+ return item.text()
+
+ def get_role_data_from_coordinate(self, row, col, *, role) -> Any:
+ idx = self.model().index(row, col)
+ item = self.item_from_index(idx)
+ role_data = item.data(role)
+ return role_data
+
+ def get_edit_key_from_coordinate(self, row, col) -> Any:
+ # overriding this might allow avoiding storing duplicate data
+ return self.get_role_data_from_coordinate(row, col, role=self.ROLE_EDIT_KEY)
+
+ def get_filter_data_from_coordinate(self, row, col) -> str:
+ filter_data = self.get_role_data_from_coordinate(row, col, role=self.ROLE_FILTER_DATA)
+ if filter_data:
+ return filter_data
+ txt = self.get_text_from_coordinate(row, col)
+ txt = txt.lower()
+ return txt
+
+ def hide_row(self, row_num):
+ """
+ row_num is for self.model(). So if there is a proxy, it is the row number
+ in that!
+ """
+ should_hide = self.should_hide(row_num)
+ if not self.current_filter and should_hide is None:
+ # no filters at all, neither date nor search
+ self.setRowHidden(row_num, QModelIndex(), False)
+ return
+ for column in self.filter_columns:
+ filter_data = self.get_filter_data_from_coordinate(row_num, column)
+ if self.current_filter in filter_data:
+ # the filter matched, but the date filter might apply
+ self.setRowHidden(row_num, QModelIndex(), bool(should_hide))
+ break
+ else:
+ # we did not find the filter in any columns, hide the item
+ self.setRowHidden(row_num, QModelIndex(), True)
+
+ def filter(self, p=None):
+ if p is not None:
+ p = p.lower()
+ self.current_filter = p
+ self.hide_rows()
+
+ def hide_rows(self):
+ for row in range(self.model().rowCount()):
+ self.hide_row(row)
+
+ def create_toolbar(self, config: 'SimpleConfig'):
+ return
+
+ def create_toolbar_buttons(self):
+ hbox = QHBoxLayout()
+ buttons = self.get_toolbar_buttons()
+ for b in buttons:
+ b.setVisible(False)
+ hbox.addWidget(b)
+ self.toolbar_buttons = buttons
+ return hbox
+
+ def create_toolbar_with_menu(self, title):
+ return create_toolbar_with_menu(self.config, title)
+
+ configvar_show_toolbar = None # type: Optional[ConfigVarWithConfig]
+ _toolbar_checkbox = None # type: Optional[QAction]
+ def show_toolbar(self, state: bool = None):
+ if state is None: # get value from config
+ if self.configvar_show_toolbar:
+ state = self.configvar_show_toolbar.get()
+ else:
+ return
+ assert isinstance(state, bool), state
+ if state == self.toolbar_shown:
+ return
+ self.toolbar_shown = state
+ for b in self.toolbar_buttons:
+ b.setVisible(state)
+ if not state:
+ self.on_hide_toolbar()
+ if self._toolbar_checkbox is not None:
+ # update the cb state now, in case the checkbox was not what triggered us
+ self._toolbar_checkbox.setChecked(state)
+
+ def on_hide_toolbar(self):
+ pass
+
+ def toggle_toolbar(self):
+ new_state = not self.toolbar_shown
+ self.show_toolbar(new_state)
+ if self.configvar_show_toolbar:
+ self.configvar_show_toolbar.set(new_state)
+
+ def add_copy_menu(self, menu: QMenu, idx) -> QMenu:
+ cc = menu.addMenu(_("Copy"))
+ for column in self.Columns:
+ if self.isColumnHidden(column):
+ continue
+ column_title = self.original_model().horizontalHeaderItem(column).text()
+ if not column_title:
+ continue
+ item_col = self.item_from_index(idx.sibling(idx.row(), column))
+ clipboard_data = item_col.data(self.ROLE_CLIPBOARD_DATA)
+ if clipboard_data is None:
+ clipboard_data = item_col.text().strip()
+ cc.addAction(column_title,
+ lambda text=clipboard_data, title=column_title:
+ self.place_text_on_clipboard(text, title=title))
+ return cc
+
+ def place_text_on_clipboard(self, text: str, *, title: str = None) -> None:
+ self.main_window.do_copy(text, title=title)
+
+ def showEvent(self, e: 'QShowEvent'):
+ super().showEvent(e)
+ if e.isAccepted() and self._pending_update:
+ self._forced_update = True
+ self.update()
+ self._forced_update = False
+
+ def maybe_defer_update(self) -> bool:
+ """Returns whether we should defer an update/refresh."""
+ defer = (not self._forced_update
+ and (not self.isVisible() or self.is_editor_open))
+ # side-effect: if we decide to defer update, the state will become stale:
+ self._pending_update = defer
+ return defer
+
+ def find_row_by_key(self, key) -> Optional[int]:
+ for row in range(0, self.std_model.rowCount()):
+ item = self.std_model.item(row, 0)
+ if item.data(self.key_role) == key:
+ return row
+
+ def refresh_all(self):
+ if self.maybe_defer_update():
+ return
+ for row in range(0, self.std_model.rowCount()):
+ item = self.std_model.item(row, 0)
+ key = item.data(self.key_role)
+ self.refresh_row(key, row)
+
+ def refresh_row(self, key: str, row: int) -> None:
+ pass
+
+ def refresh_item(self, key):
+ row = self.find_row_by_key(key)
+ if row is not None:
+ self.refresh_row(key, row)
+
+ def delete_item(self, key):
+ row = self.find_row_by_key(key)
+ if row is not None:
+ self.std_model.takeRow(row)
+ self.hide_if_empty()
+
+
diff --git a/electrum/gui/qt/network_dialog.py b/electrum/gui/qt/network_dialog.py
new file mode 100644
index 000000000000..8d72deb1f83d
--- /dev/null
+++ b/electrum/gui/qt/network_dialog.py
@@ -0,0 +1,640 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2012 thomasv@gitorious
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from enum import IntEnum
+
+from PyQt6.QtCore import Qt, pyqtSignal, pyqtSlot
+from PyQt6.QtWidgets import (
+ QTreeWidget, QTreeWidgetItem, QMenu, QGridLayout, QComboBox, QLineEdit, QDialog, QVBoxLayout, QHeaderView,
+ QCheckBox, QTabWidget, QWidget, QLabel, QPushButton, QHBoxLayout,
+ QListWidget, QListWidgetItem,
+)
+from PyQt6.QtGui import QIntValidator
+
+from electrum.i18n import _
+from electrum import blockchain
+from electrum.interface import ServerAddr, PREFERRED_NETWORK_PROTOCOL
+from electrum.network import Network, ProxySettings, is_valid_host, is_valid_port
+from electrum.logging import get_logger
+from electrum.util import is_valid_websocket_url
+from electrum.gui import messages
+
+from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
+from .util import (
+ Buttons, CloseButton, HelpButton, read_QIcon, char_width_in_lineedit, PasswordLineEdit, Spinner, HelpLabel
+)
+
+_logger = get_logger(__name__)
+
+protocol_names = ['TCP', 'SSL']
+protocol_letters = 'ts'
+
+
+class NetworkDialog(QDialog, QtEventListener):
+ def __init__(self, *, network: Network):
+ QDialog.__init__(self)
+ self.setWindowTitle(_('Network'))
+ self.setMinimumSize(500, 500)
+ self.tabs = tabs = QTabWidget()
+ self._blockchain_tab = ServerWidget(network)
+ self._proxy_tab = ProxyWidget(network)
+ self._nostr_tab = NostrWidget(network)
+ tabs.addTab(self._blockchain_tab, _('Server'))
+ tabs.addTab(self._nostr_tab, _('Nostr'))
+ tabs.addTab(self._proxy_tab, _('Proxy'))
+ vbox = QVBoxLayout(self)
+ vbox.addWidget(self.tabs)
+ vbox.addLayout(Buttons(CloseButton(self)))
+
+ def show(self, *, proxy_tab: bool = False):
+ super().show()
+ self.tabs.setCurrentWidget(self._proxy_tab if proxy_tab else self._blockchain_tab)
+
+
+class NodesListWidget(QTreeWidget):
+ """List of connected servers."""
+
+ SERVER_ADDR_ROLE = Qt.ItemDataRole.UserRole + 100
+ CHAIN_ID_ROLE = Qt.ItemDataRole.UserRole + 101
+ ITEMTYPE_ROLE = Qt.ItemDataRole.UserRole + 102
+
+ class ItemType(IntEnum):
+ CHAIN = 0
+ CONNECTED_SERVER = 1
+ DISCONNECTED_SERVER = 2
+ TOPLEVEL = 3
+
+ followServer = pyqtSignal([ServerAddr], arguments=['server'])
+ followChain = pyqtSignal([str], arguments=['chain_id'])
+ setServer = pyqtSignal([str], arguments=['server'])
+
+ def __init__(self, *, network: Network):
+ QTreeWidget.__init__(self)
+ self.setHeaderLabels([_('Server'), _('Height')])
+ self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
+ self.customContextMenuRequested.connect(self.create_menu)
+ self.network = network
+
+ def create_menu(self, position):
+ item = self.currentItem()
+ if not item:
+ return
+ item_type = item.data(0, self.ITEMTYPE_ROLE)
+ menu = QMenu()
+ if item_type in [self.ItemType.CONNECTED_SERVER, self.ItemType.DISCONNECTED_SERVER]:
+ server = item.data(0, self.SERVER_ADDR_ROLE) # type: ServerAddr
+ if item_type == self.ItemType.CONNECTED_SERVER:
+ def do_follow_server():
+ self.followServer.emit(server)
+ menu.addAction(read_QIcon("chevron-right.png"), _("Use as server"), do_follow_server)
+ elif item_type == self.ItemType.DISCONNECTED_SERVER:
+ def do_set_server():
+ self.setServer.emit(str(server))
+ menu.addAction(read_QIcon("chevron-right.png"), _("Use as server"), do_set_server)
+
+ def set_bookmark(*, add: bool):
+ self.network.set_server_bookmark(server, add=add)
+ self.update()
+
+ if self.network.is_server_bookmarked(server):
+ menu.addAction(read_QIcon("bookmark_remove.png"), _("Remove from bookmarks"), lambda: set_bookmark(add=False))
+ else:
+ menu.addAction(read_QIcon("bookmark_add.png"), _("Bookmark this server"), lambda: set_bookmark(add=True))
+ elif item_type == self.ItemType.CHAIN:
+ chain_id = item.data(0, self.CHAIN_ID_ROLE)
+
+ def do_follow_chain():
+ self.followChain.emit(chain_id)
+
+ menu.addAction(_("Follow this branch"), do_follow_chain)
+ else:
+ return
+ menu.exec(self.viewport().mapToGlobal(position))
+
+ def keyPressEvent(self, event):
+ if event.key() in [Qt.Key.Key_F2, Qt.Key.Key_Return, Qt.Key.Key_Enter]:
+ self.on_activated(self.currentItem(), self.currentColumn())
+ else:
+ QTreeWidget.keyPressEvent(self, event)
+
+ def on_activated(self, item, column):
+ # on 'enter' we show the menu
+ pt = self.visualItemRect(item).bottomLeft()
+ pt.setX(50)
+ self.customContextMenuRequested.emit(pt)
+
+ def update(self):
+ self.clear()
+ network = self.network
+
+ # connected servers
+ connected_servers_item = QTreeWidgetItem([_("Connected nodes"), ''])
+ connected_servers_item.setData(0, self.ITEMTYPE_ROLE, self.ItemType.TOPLEVEL)
+ chains = network.get_blockchains()
+ n_chains = len(chains)
+ for chain_id, interfaces in chains.items():
+ b = blockchain.blockchains.get(chain_id)
+ if b is None:
+ continue
+ name = b.get_name()
+ if n_chains > 1:
+ x = QTreeWidgetItem([name + '@%d'%b.get_max_forkpoint(), '%d'%b.height()])
+ x.setData(0, self.ITEMTYPE_ROLE, self.ItemType.CHAIN)
+ x.setData(0, self.CHAIN_ID_ROLE, b.get_id())
+ else:
+ x = connected_servers_item
+ for i in interfaces:
+ item = QTreeWidgetItem([f"{i.server.to_friendly_name()}", '%d'%i.tip])
+ item.setData(0, self.ITEMTYPE_ROLE, self.ItemType.CONNECTED_SERVER)
+ item.setData(0, self.SERVER_ADDR_ROLE, i.server)
+ item.setToolTip(0, str(i.server))
+ if i == network.interface:
+ item.setIcon(0, read_QIcon("chevron-right.png"))
+ elif network.is_server_bookmarked(i.server):
+ item.setIcon(0, read_QIcon("bookmark.png"))
+ x.addChild(item)
+ if n_chains > 1:
+ connected_servers_item.addChild(x)
+
+ # disconnected servers
+ disconnected_servers_item = QTreeWidgetItem([_("Other known servers"), ""])
+ disconnected_servers_item.setData(0, self.ITEMTYPE_ROLE, self.ItemType.TOPLEVEL)
+ for server in network.get_disconnected_server_addrs():
+ item = QTreeWidgetItem([server.to_friendly_name(), ""])
+ item.setData(0, self.ITEMTYPE_ROLE, self.ItemType.DISCONNECTED_SERVER)
+ item.setData(0, self.SERVER_ADDR_ROLE, server)
+ if network.is_server_bookmarked(server):
+ item.setIcon(0, read_QIcon("bookmark.png"))
+ disconnected_servers_item.addChild(item)
+
+ self.addTopLevelItem(connected_servers_item)
+ self.addTopLevelItem(disconnected_servers_item)
+
+ connected_servers_item.setExpanded(True)
+ for i in range(connected_servers_item.childCount()):
+ connected_servers_item.child(i).setExpanded(True)
+ disconnected_servers_item.setExpanded(True)
+
+ # headers
+ h = self.header()
+ h.setStretchLastSection(False)
+ h.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
+ h.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
+
+ super().update()
+
+
+class ProxyWidget(QWidget):
+ PROXY_MODES = {
+ 'socks4': 'SOCKS4',
+ 'socks5': 'SOCKS5/TOR'
+ }
+
+ torProbeFinished = pyqtSignal([str, int], arguments=['host', 'port'])
+
+ def __init__(self, network: Network, parent=None):
+ super().__init__(parent)
+ self.network = network
+ self.config = network.config
+
+ fixed_width_port = 6 * char_width_in_lineedit()
+
+ # proxy setting.
+ self.proxy_cb = QCheckBox(_('Use proxy'))
+ self.proxy_mode = QComboBox()
+ for k, v in self.PROXY_MODES.items():
+ self.proxy_mode.addItem(v, k)
+ self.proxy_mode.setCurrentIndex(1)
+ self.proxy_host = QLineEdit()
+ self.proxy_port = QLineEdit()
+ self.proxy_port.setFixedWidth(fixed_width_port)
+ self.proxy_port_validator = QIntValidator(1, 65535)
+ self.proxy_port.setValidator(self.proxy_port_validator)
+
+ self.proxy_user = QLineEdit()
+ self.proxy_user.setPlaceholderText(_("Proxy username"))
+ self.proxy_password = PasswordLineEdit()
+ self.proxy_password.setPlaceholderText(_("Proxy password"))
+
+ grid = QGridLayout(self)
+ grid.setSpacing(8)
+
+ grid.addWidget(self.proxy_cb, 0, 0, 1, 4)
+ proxy_helpbutton = HelpButton(
+ _('Proxy settings apply to all connections: with Electrum servers, but also with third-party services.'))
+ grid.addWidget(proxy_helpbutton, 0, 4, alignment=Qt.AlignmentFlag.AlignRight)
+ grid.addWidget(self.proxy_mode, 1, 0, 1, 1)
+ grid.addWidget(self.proxy_host, 1, 1, 1, 3)
+ grid.addWidget(self.proxy_port, 1, 4, 1, 1)
+ grid.addWidget(self.proxy_user, 2, 1, 1, 2)
+ grid.addWidget(self.proxy_password, 2, 3, 1, 2)
+
+ detect_l = QHBoxLayout()
+ self.detect_button = QPushButton(_('Detect Tor proxy'))
+ self.spinner = Spinner()
+ self.spinner.setMargin(5)
+ detect_l.addWidget(self.detect_button)
+ detect_l.addWidget(self.spinner)
+
+ grid.addLayout(detect_l, 3, 0, 1, 5, alignment=Qt.AlignmentFlag.AlignLeft)
+
+ spacer = QVBoxLayout()
+ spacer.addStretch(1)
+ grid.addLayout(spacer, 4, 0, 1, 5)
+
+ self.update_from_config()
+ self.update()
+
+ # connect signal handlers after init from config
+ self.proxy_cb.stateChanged.connect(self.on_proxy_enable_toggle)
+ self.proxy_mode.currentIndexChanged.connect(self.on_proxy_settings_changed)
+ self.proxy_host.editingFinished.connect(self.on_proxy_settings_changed)
+ self.proxy_port.editingFinished.connect(self.on_proxy_settings_changed)
+ self.proxy_user.editingFinished.connect(self.on_proxy_settings_changed)
+ self.proxy_password.editingFinished.connect(self.on_proxy_settings_changed)
+ self.detect_button.clicked.connect(self.detect_tor)
+
+ self.torProbeFinished.connect(self.on_tor_probe_finished)
+
+ def update(self):
+ enabled = self.proxy_cb.isChecked() and self.config.cv.NETWORK_PROXY.is_modifiable()
+ for item in [
+ self.proxy_mode, self.proxy_host, self.proxy_port, self.proxy_user, self.proxy_password,
+ self.detect_button
+ ]:
+ item.setEnabled(enabled)
+
+ if not self.proxy_port.hasAcceptableInput() and not is_valid_port(self.proxy_port.text()):
+ return
+
+ if not is_valid_host(self.proxy_host.text()):
+ return
+
+ net_params = self.network.get_parameters()
+ proxy = self.get_proxy_settings()
+ net_params = net_params._replace(proxy=proxy)
+ self.network.run_from_another_thread(self.network.set_parameters(net_params))
+
+ def update_from_config(self):
+ proxy = ProxySettings.from_config(self.config)
+ self.proxy_cb.setChecked(proxy.enabled)
+ self.proxy_mode.setCurrentText(self.PROXY_MODES.get(proxy.mode))
+ self.proxy_host.setText(proxy.host)
+ self.proxy_port.setText(proxy.port)
+ self.proxy_user.setText(proxy.user)
+ self.proxy_password.setText(proxy.password)
+
+ if not self.config.cv.NETWORK_PROXY.is_modifiable():
+ for w in [
+ self.proxy_cb, self.proxy_mode, self.proxy_host, self.proxy_port,
+ self.proxy_user, self.proxy_password, self.detect_button
+ ]:
+ w.setEnabled(False)
+
+ def on_proxy_enable_toggle(self):
+ # probe if enabled and no pre-existing settings
+ # if self.proxy_cb.isChecked() and (not self.proxy_host.text() or not self.proxy_port.text()):
+ # self.detect_tor()
+ self.update()
+
+ def on_proxy_settings_changed(self):
+ self.update()
+
+ def get_proxy_settings(self) -> ProxySettings:
+ proxy = ProxySettings()
+ proxy.enabled = self.proxy_cb.isChecked()
+ proxy.mode = self.proxy_mode.currentData()
+ proxy.host = self.proxy_host.text()
+ proxy.port = self.proxy_port.text()
+ proxy.user = self.proxy_user.text()
+ proxy.password = self.proxy_password.text()
+ return proxy
+
+ def detect_tor(self):
+ self.detect_button.setEnabled(False)
+ self.spinner.setVisible(True)
+ ProxySettings.probe_tor(self.torProbeFinished.emit) # via signal
+
+ @pyqtSlot(str, int)
+ def on_tor_probe_finished(self, host: str, port: int):
+ self.detect_button.setEnabled(True)
+ self.spinner.setVisible(False)
+ if host:
+ self.proxy_mode.setCurrentIndex(1)
+ self.proxy_host.setText(host)
+ self.proxy_port.setText(str(port))
+ self.update()
+
+
+class ConnectMode(IntEnum):
+ AUTOCONNECT = 0
+ MANUAL = 1
+ ONESERVER = 2
+
+class ServerWidget(QWidget, QtEventListener):
+ CONNECT_MODES = {
+ ConnectMode.AUTOCONNECT: messages.MSG_CONNECTMODE_AUTOCONNECT,
+ ConnectMode.MANUAL: messages.MSG_CONNECTMODE_MANUAL,
+ ConnectMode.ONESERVER: messages.MSG_CONNECTMODE_ONESERVER,
+ }
+
+ server_e_valid = pyqtSignal(bool)
+
+ def __init__(self, network: Network, parent=None):
+ super().__init__(parent)
+ self.network = network
+ self.config = network.config
+
+ self.setLayout(QVBoxLayout())
+
+ grid = QGridLayout()
+
+ self.connect_combo = QComboBox()
+ for i, v in sorted(self.CONNECT_MODES.items()):
+ self.connect_combo.addItem(v, i)
+ self.connect_combo.currentIndexChanged.connect(self.on_server_settings_changed)
+ grid.addWidget(QLabel(_('Connection mode') + ':'), 0, 0)
+ msg = (
+ f"""
+ {messages.MSG_CONNECTMODE_SERVER_HELP}
+ """
+ )
+ grid.addWidget(HelpButton(msg), 0, 4)
+ grid.addWidget(self.connect_combo, 0, 1, 1, 3)
+
+ self.server_e = QLineEdit()
+ self.server_e.textChanged.connect(self.validate_server_e)
+ self.server_e.editingFinished.connect(self.on_server_settings_changed)
+ grid.addWidget(QLabel(_('Server') + ':'), 1, 0)
+ grid.addWidget(self.server_e, 1, 1, 1, 3)
+ grid.addWidget(HelpButton(messages.MSG_CONNECTMODE_SERVER_HELP), 1, 4)
+
+ self.status_label_header = QLabel(_('Status') + ':')
+ self.status_label = QLabel('')
+ self.status_label_helpbutton = HelpButton(messages.MSG_CONNECTMODE_NODES_HELP)
+ grid.addWidget(self.status_label_header, 2, 0)
+ grid.addWidget(self.status_label, 2, 1, 1, 3)
+ grid.addWidget(self.status_label_helpbutton, 2, 4)
+
+ msg = _('This is the height of your local copy of the blockchain.')
+ self.height_label_header = QLabel(_('Blockchain') + ':')
+ self.height_label = QLabel('')
+ self.height_label_helpbutton = HelpButton(msg)
+ grid.addWidget(self.height_label_header, 3, 0)
+ grid.addWidget(self.height_label, 3, 1)
+ grid.addWidget(self.height_label_helpbutton, 3, 4)
+
+ self.split_label = QLabel('')
+ grid.addWidget(self.split_label, 4, 1, 1, 3)
+
+ self.layout().addLayout(grid)
+
+ self.nodes_list_widget = NodesListWidget(network=self.network)
+ self.nodes_list_widget.followServer.connect(self.follow_server)
+ self.nodes_list_widget.followChain.connect(self.follow_branch)
+
+ def do_set_server(server):
+ self.server_e.setText(server)
+ if self.is_auto_connect():
+ # switch to manual mode as the user manually selected a server
+ self.set_connect_mode(ConnectMode.MANUAL, block_signals=True)
+ self.on_server_settings_changed()
+ self.nodes_list_widget.setServer.connect(do_set_server)
+
+ self.layout().addWidget(self.nodes_list_widget)
+ self.nodes_list_widget.update()
+
+ self.register_callbacks()
+ self.destroyed.connect(lambda: self.unregister_callbacks())
+
+ def showEvent(self, event):
+ # gets called every time the ServerWidget is shown, when opening it and when
+ # switching between the tabs.
+ super().showEvent(event)
+ _logger.debug(f"showing ServerWidget")
+ # If the user entered garbage the previous time the ServerWidget was open this will restore
+ # it back to the current config
+ self.update_from_config()
+ self.update()
+
+ @qt_event_listener
+ def on_event_network_updated(self):
+ self.nodes_list_widget.update() # NOTE: move event handling to widget itself?
+ self.update()
+
+ def is_auto_connect(self):
+ return self.connect_combo.currentIndex() == ConnectMode.AUTOCONNECT
+
+ def is_one_server(self):
+ return self.connect_combo.currentIndex() == ConnectMode.ONESERVER
+
+ def set_connect_mode(self, connect_mode: ConnectMode, *, block_signals = False):
+ # if block_signals = True the on_server_settings_changed won't get called when changing the index
+ assert isinstance(connect_mode, ConnectMode), connect_mode
+ self.connect_combo.blockSignals(block_signals)
+ self.connect_combo.setCurrentIndex(connect_mode)
+ self.connect_combo.blockSignals(False)
+
+ def on_server_settings_changed(self):
+ if not self.network._was_started:
+ self.update()
+ return
+
+ current_net_params = self.network.get_parameters()
+ new_server = ServerAddr.from_str_with_inference(self.server_e.text().strip())
+ new_server = new_server or current_net_params.server # keep existing server while input is invalid
+
+ settings_changed = False
+ if new_server != current_net_params.server:
+ settings_changed = True
+ if self.is_auto_connect() != current_net_params.auto_connect:
+ settings_changed = True
+ if self.is_one_server() != current_net_params.oneserver:
+ settings_changed = True
+
+ if settings_changed:
+ _logger.debug(
+ f"ServerWidget.on_server_settings_changed:\n"
+ f"[server: {current_net_params.server} -> {new_server}]\n"
+ f"[auto_connect: {current_net_params.auto_connect} -> {self.is_auto_connect()}]\n"
+ f"[oneserver: {current_net_params.oneserver} -> {self.is_one_server()}]"
+ )
+ self.set_server(
+ new_server,
+ auto_connect=self.is_auto_connect(),
+ one_server=self.is_one_server(),
+ )
+ self.update()
+
+ def update(self):
+ self.server_e.setEnabled(self.config.cv.NETWORK_SERVER.is_modifiable() and not self.is_auto_connect())
+ if self.is_auto_connect():
+ self.server_e.clear()
+ elif not self.server_e.text():
+ self.server_e.setText(self.config.NETWORK_SERVER or "")
+ for item in [
+ self.status_label_header, self.status_label, self.status_label_helpbutton,
+ self.height_label_header, self.height_label, self.height_label_helpbutton]:
+ item.setVisible(self.network._was_started)
+ self.validate_server_e()
+ msg = _('Fork detection disabled') if self.is_one_server() else ''
+ if self.network._was_started:
+ # Network was started, so we don't run in initial setup wizard.
+ # behavior in this case is to apply changes immediately.
+ # Also, we show block height and potential chain tips
+ height_str = _('{} blocks').format(self.network.get_local_height())
+ self.height_label.setText(height_str)
+ self.status_label.setText(self.network.get_status())
+ chains = self.network.get_blockchains()
+ if len(chains) > 1:
+ chain = self.network.blockchain()
+ forkpoint = chain.get_max_forkpoint()
+ name = chain.get_name()
+ msg = _('Fork detected at block {0}').format(forkpoint) + '\n'
+ if self.is_auto_connect():
+ msg += _('You are following branch {}').format(name)
+ else:
+ msg += _('Your server is on branch {0} ({1} blocks)').format(name, chain.get_branch_size())
+ self.split_label.setText(msg)
+
+ def validate_server_e(self):
+ if not self.server_e.isEnabled():
+ self.server_e.setStyleSheet("")
+ self.server_e_valid.emit(True)
+ return
+ server = ServerAddr.from_str_with_inference(self.server_e.text())
+ self.server_e.setStyleSheet("background-color: rgba(255, 0, 0, 0.2);" if not server else "")
+ self.server_e_valid.emit(server is not None)
+
+ def update_from_config(self):
+ auto_connect = self.config.NETWORK_AUTO_CONNECT
+ one_server = self.config.NETWORK_ONESERVER
+ v = ConnectMode.AUTOCONNECT if auto_connect else ConnectMode.ONESERVER if one_server else ConnectMode.MANUAL
+ self.set_connect_mode(v)
+
+ server = self.config.NETWORK_SERVER
+ self.server_e.setText(server)
+
+ self.server_e.setEnabled(self.config.cv.NETWORK_SERVER.is_modifiable() and not auto_connect)
+ self.nodes_list_widget.setEnabled(self.config.cv.NETWORK_SERVER.is_modifiable())
+ _logger.debug(f"update from config: done")
+
+ def follow_branch(self, chain_id):
+ self.network.run_from_another_thread(self.network.follow_chain_given_id(chain_id))
+ # follow_chain_given_id connects to random interface, so set connect_mode back to AUTOCONNECT
+ self.set_connect_mode(ConnectMode.AUTOCONNECT, block_signals=True)
+ self.update()
+
+ def follow_server(self, server: ServerAddr):
+ try:
+ self.network.follow_chain_given_server(server)
+ except KeyError:
+ _logger.debug(f"follow_server: cannot follow, not connected to {server.net_addr_str()}.")
+ return
+
+ self.server_e.setText(str(server))
+ if self.is_auto_connect():
+ # the user manually selected a server, so the ConnectMode gets set to MANUAL
+ self.set_connect_mode(ConnectMode.MANUAL, block_signals=True)
+
+ self.set_server(
+ server=server,
+ auto_connect=False,
+ one_server=self.is_one_server(),
+ )
+ self.update()
+
+ def set_server(self, server: ServerAddr, *, auto_connect: bool, one_server: bool):
+ current_net_params = self.network.get_parameters()
+ new_net_params = current_net_params._replace(
+ server=server,
+ auto_connect=auto_connect,
+ oneserver=one_server,
+ )
+ _logger.debug(f"set_server: {new_net_params=}")
+ self.network.run_from_another_thread(self.network.set_parameters(new_net_params))
+
+
+class NostrWidget(QWidget, QtEventListener):
+
+ def __init__(self, network: Network, parent=None):
+ super().__init__(parent)
+ self.network = network
+ self.config = network.config
+ vbox = QVBoxLayout()
+ self.setLayout(vbox)
+ grid = QGridLayout()
+ nostr_relays_label = QLabel(self.config.cv.NOSTR_RELAYS.get_short_desc())
+ nostr_helpbutton = HelpButton(self.config.cv.NOSTR_RELAYS.get_long_desc())
+ grid.addWidget(nostr_relays_label, 0, 0)
+ grid.addWidget(nostr_helpbutton, 0, 1)
+ vbox.addLayout(grid)
+
+ self.relays_list = QListWidget()
+ self.relay_edit = QLineEdit()
+ self.relay_edit.textChanged.connect(self.on_relay_edited)
+ vbox.addWidget(self.relays_list)
+ vbox.addStretch()
+ self.add_button = QPushButton(_('Add'))
+ self.add_button.clicked.connect(self.add_relay)
+ self.add_button.setEnabled(False)
+ remove_button = QPushButton(_('Remove'))
+ remove_button.clicked.connect(self.remove_relay)
+ reset_button = QPushButton(_('Reset'))
+ reset_button.clicked.connect(self.reset_relays)
+ buttons = Buttons(self.relay_edit, self.add_button, remove_button, reset_button)
+ vbox.addLayout(buttons)
+ self.update_list()
+
+ def on_relay_edited(self, text):
+ self.add_button.setEnabled(is_valid_websocket_url(text))
+
+ def update_list(self):
+ self.relays_list.clear()
+ for relay in self.config.get_nostr_relays():
+ item = QListWidgetItem(relay)
+ self.relays_list.addItem(item)
+
+ def add_relay(self):
+ relay = self.relay_edit.text()
+ self.config.add_nostr_relay(relay)
+ self.update_list()
+
+ def remove_relay(self):
+ item = self.relays_list.currentItem()
+ if item is None:
+ return
+ self.config.remove_nostr_relay(item.text())
+ self.update_list()
+
+ def reset_relays(self):
+ self.config.NOSTR_RELAYS = None
+ self.update_list()
diff --git a/electrum/gui/qt/new_channel_dialog.py b/electrum/gui/qt/new_channel_dialog.py
new file mode 100644
index 000000000000..e8af1ea65c0b
--- /dev/null
+++ b/electrum/gui/qt/new_channel_dialog.py
@@ -0,0 +1,189 @@
+from typing import TYPE_CHECKING, Optional, Callable, Sequence
+from PyQt6.QtWidgets import QLabel, QVBoxLayout, QGridLayout, QPushButton, QComboBox, QLineEdit, QHBoxLayout
+
+import electrum_ecc as ecc
+
+from electrum.i18n import _
+from electrum.lnutil import MIN_FUNDING_SAT
+from electrum.lnworker import hardcoded_trampoline_nodes
+from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates
+from electrum.fee_policy import FeePolicy
+from electrum.lntransport import extract_nodeid, ConnStringFormatError
+
+from .util import (WindowModalDialog, Buttons, OkButton, CancelButton,
+ EnterButton, WWLabel, char_width_in_lineedit)
+from .amountedit import BTCAmountEdit
+from .my_treeview import create_toolbar_with_menu
+
+if TYPE_CHECKING:
+ from electrum.transaction import PartialTxInput
+ from .main_window import ElectrumWindow
+
+
+class NewChannelDialog(WindowModalDialog):
+
+ def __init__(
+ self,
+ window: 'ElectrumWindow',
+ *,
+ amount_sat: Optional[int] = None,
+ min_amount_sat: Optional[int] = None,
+ get_coins: Optional[Callable[..., Sequence['PartialTxInput']]] = None,
+ ):
+ WindowModalDialog.__init__(self, window, _('Open Channel'))
+ self.window = window
+ self.network = window.network
+ self.config = window.config
+ self.lnworker = self.window.wallet.lnworker
+ self.trampolines = hardcoded_trampoline_nodes()
+ self.trampoline_names = list(self.trampolines.keys())
+ self.min_amount_sat = min_amount_sat or MIN_FUNDING_SAT
+ self.get_coins = get_coins
+ vbox = QVBoxLayout(self)
+ toolbar, menu = create_toolbar_with_menu(self.config, '')
+ menu.addConfig(
+ self.config.cv.LIGHTNING_USE_RECOVERABLE_CHANNELS,
+ checked=self.lnworker.has_recoverable_channels(),
+ ).setEnabled(self.lnworker.can_have_recoverable_channels())
+ vbox.addLayout(toolbar)
+ msg = _('Choose a remote node and an amount to fund the channel.')
+ msg += '\n' + _('Minimum required amount: {}').format(self.window.format_amount_and_units(self.min_amount_sat))
+ vbox.addWidget(WWLabel(msg))
+ if self.network.channel_db:
+ vbox.addWidget(QLabel(_('Enter Remote Node ID or connection string or invoice')))
+ self.remote_nodeid = QLineEdit()
+ self.remote_nodeid.setMinimumWidth(700)
+ self.remote_nodeid.textChanged.connect(self.maybe_enable_ok_button)
+ self.suggest_button = QPushButton(self, text=_('Suggest Peer'))
+ self.suggest_button.clicked.connect(self.on_suggest)
+ else:
+ self.trampoline_combo = QComboBox()
+ self.trampoline_combo.addItems(self.trampoline_names)
+ # index 1 is "Electrum trampoline" on mainnet, this defaults to -1 if 1 is not available
+ self.trampoline_combo.setCurrentIndex(1)
+ self.trampoline_combo.currentIndexChanged.connect(self.maybe_enable_ok_button)
+ self.amount_e = BTCAmountEdit(self.window.get_decimal_point)
+ self.amount_e.setAmount(amount_sat)
+ self.amount_e.textChanged.connect(self.maybe_enable_ok_button)
+
+ btn_width = 10 * char_width_in_lineedit()
+ self.min_button = EnterButton(_("Min"), self.spend_min)
+ self.min_button.setEnabled(bool(self.min_amount_sat))
+ self.min_button.setFixedWidth(btn_width)
+ self.max_button = EnterButton(_("Max"), self.spend_max)
+ self.max_button.setFixedWidth(btn_width)
+ self.max_button.setCheckable(True)
+ self.clear_button = QPushButton(self, text=_('Clear'))
+ self.clear_button.clicked.connect(self.on_clear)
+ self.clear_button.setFixedWidth(btn_width)
+ h = QGridLayout()
+ if self.network.channel_db:
+ h.addWidget(QLabel(_('Remote Node ID')), 0, 0)
+ h.addWidget(self.remote_nodeid, 0, 1, 1, 4)
+ h.addWidget(self.suggest_button, 0, 5)
+ else:
+ h.addWidget(QLabel(_('Remote Node')), 0, 0)
+ h.addWidget(self.trampoline_combo, 0, 1, 1, 4)
+ h.addWidget(QLabel('Amount'), 2, 0)
+
+ amt_hbox = QHBoxLayout()
+ amt_hbox.setContentsMargins(0, 0, 0, 0)
+ amt_hbox.addWidget(self.amount_e)
+ amt_hbox.addWidget(self.min_button)
+ amt_hbox.addWidget(self.max_button)
+ amt_hbox.addWidget(self.clear_button)
+ amt_hbox.addStretch()
+ h.addLayout(amt_hbox, 2, 1, 1, 4)
+
+ vbox.addLayout(h)
+ vbox.addStretch()
+ self.ok_button = OkButton(self)
+ self.ok_button.setDefault(True)
+ self.maybe_enable_ok_button()
+ vbox.addLayout(Buttons(CancelButton(self), self.ok_button))
+
+ def maybe_enable_ok_button(self):
+ enable = True
+ if self.network.channel_db:
+ try:
+ extract_nodeid(str(self.remote_nodeid.text()).strip())
+ except ConnStringFormatError:
+ enable = False
+ else:
+ try:
+ self.trampoline_names[self.trampoline_combo.currentIndex()]
+ except IndexError:
+ enable = False
+ if not self.amount_e.get_amount():
+ enable = False
+ self.ok_button.setEnabled(enable)
+
+ def on_suggest(self):
+ self.network.start_gossip()
+ nodeid = (self.lnworker.suggest_peer() or b"").hex()
+ if not nodeid:
+ self.remote_nodeid.setText("")
+ self.remote_nodeid.setPlaceholderText(
+ _("Couldn't find suitable peer yet, try again later.")
+ )
+ else:
+ self.remote_nodeid.setText(nodeid)
+ self.remote_nodeid.repaint() # macOS hack for #6269
+
+ def on_clear(self):
+ self.amount_e.setText('')
+ self.amount_e.setFrozen(False)
+ self.amount_e.repaint() # macOS hack for #6269
+ if self.network.channel_db:
+ self.remote_nodeid.setText('')
+ self.remote_nodeid.repaint() # macOS hack for #6269
+ self.max_button.setChecked(False)
+ self.max_button.repaint() # macOS hack for #6269
+
+ def spend_min(self):
+ self.max_button.setChecked(False)
+ self.amount_e.setFrozen(False)
+ self.amount_e.setAmount(self.min_amount_sat)
+
+ def spend_max(self):
+ self.amount_e.setFrozen(self.max_button.isChecked())
+ if not self.max_button.isChecked():
+ return
+ dummy_nodeid = ecc.GENERATOR.get_public_key_bytes(compressed=True)
+ make_tx = self.window.mktx_for_open_channel(funding_sat='!', node_id=dummy_nodeid, get_coins=self.get_coins)
+ try:
+ tx = make_tx(FeePolicy(self.config.FEE_POLICY))
+ except (NotEnoughFunds, NoDynamicFeeEstimates) as e:
+ self.max_button.setChecked(False)
+ self.amount_e.setFrozen(False)
+ self.window.show_error(str(e))
+ return
+ amount = tx.output_value()
+ amount = min(amount, self.config.LIGHTNING_MAX_FUNDING_SAT)
+ self.amount_e.setAmount(amount)
+
+ def run(self):
+ if not self.exec():
+ return
+ if self.max_button.isChecked() and self.amount_e.get_amount() < self.config.LIGHTNING_MAX_FUNDING_SAT:
+ # if 'max' enabled and amount is strictly less than max allowed,
+ # that means we have fewer coins than max allowed, and hence we can
+ # spend all coins
+ funding_sat = '!'
+ else:
+ funding_sat = self.amount_e.get_amount()
+ if not funding_sat:
+ return
+ if funding_sat != '!':
+ if self.min_amount_sat and funding_sat < self.min_amount_sat:
+ self.window.show_error(_('Amount too low'))
+ return
+ if self.network.channel_db:
+ connect_str = str(self.remote_nodeid.text()).strip()
+ else:
+ name = self.trampoline_names[self.trampoline_combo.currentIndex()]
+ connect_str = str(self.trampolines[name])
+ if not connect_str:
+ return
+ self.window.open_channel(connect_str, funding_sat, get_coins=self.get_coins)
+ return True
diff --git a/electrum/gui/qt/password_dialog.py b/electrum/gui/qt/password_dialog.py
new file mode 100644
index 000000000000..44e11434ffde
--- /dev/null
+++ b/electrum/gui/qt/password_dialog.py
@@ -0,0 +1,315 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2013 ecdsa@github
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import re
+import math
+
+from PyQt6.QtCore import Qt
+from PyQt6.QtGui import QPixmap
+from PyQt6.QtWidgets import QLabel, QGridLayout, QVBoxLayout, QCheckBox
+
+from electrum.i18n import _
+from electrum.plugin import run_hook
+
+from .util import icon_path, WindowModalDialog, OkButton, CancelButton, Buttons, PasswordLineEdit
+
+
+def check_password_strength(password):
+
+ '''
+ Check the strength of the password entered by the user and return back the same
+ :param password: password entered by user in New Password
+ :return: password strength Weak or Medium or Strong
+ '''
+ password = password
+ n = math.log(len(set(password)))
+ num = re.search("[0-9]", password) is not None and re.match("^[0-9]*$", password) is None
+ caps = password != password.upper() and password != password.lower()
+ extra = re.match("^[a-zA-Z0-9]*$", password) is None
+ score = len(password)*(n + caps + num + extra)/20
+ password_strength = {0:"Weak",1:"Medium",2:"Strong",3:"Very Strong"}
+ return password_strength[min(3, int(score))]
+
+
+PW_NEW, PW_CHANGE, PW_PASSPHRASE = range(0, 3)
+
+MSG_ENTER_PASSWORD = _("Choose a password to encrypt your wallet keys.") + '\n'\
+ + _("Leave this field empty if you want to disable encryption.")
+
+
+class PasswordLayout(object):
+
+ titles = [_("Enter Password"), _("Change Password"), _("Enter Passphrase")]
+
+ def __init__(self, msg, kind, OK_button, wallet=None):
+ self.wallet = wallet
+
+ self.pw = PasswordLineEdit()
+ self.new_pw = PasswordLineEdit()
+ self.conf_pw = PasswordLineEdit()
+ self.kind = kind
+ self.OK_button = OK_button
+
+ vbox = QVBoxLayout()
+ label = QLabel(msg + "\n")
+ label.setWordWrap(True)
+
+ self.grid = grid = QGridLayout()
+ grid.setSpacing(8)
+ grid.setColumnMinimumWidth(0, 150)
+ grid.setColumnMinimumWidth(1, 100)
+ grid.setColumnStretch(1,1)
+
+ if kind == PW_PASSPHRASE:
+ vbox.addWidget(label)
+ msgs = [_('Passphrase:'), _('Confirm Passphrase:')]
+ else:
+ logo_grid = QGridLayout()
+ logo_grid.setSpacing(8)
+ logo_grid.setColumnMinimumWidth(0, 70)
+ logo_grid.setColumnStretch(1,1)
+
+ logo = QLabel()
+ logo.setAlignment(Qt.AlignmentFlag.AlignCenter)
+
+ logo_grid.addWidget(logo, 0, 0)
+ logo_grid.addWidget(label, 0, 1, 1, 2)
+ vbox.addLayout(logo_grid)
+
+ m1 = _('New Password:') if kind == PW_CHANGE else _('Password:')
+ msgs = [m1, _('Confirm Password:')]
+ if wallet and wallet.has_password() and not wallet.storage.is_encrypted_with_hw_device():
+ grid.addWidget(QLabel(_('Current Password:')), 0, 0)
+ grid.addWidget(self.pw, 0, 1)
+ lockfile = "lock.png"
+ else:
+ lockfile = "unlock.png"
+ logo.setPixmap(QPixmap(icon_path(lockfile))
+ .scaledToWidth(36, mode=Qt.TransformationMode.SmoothTransformation))
+
+ self.new_password_label = QLabel(msgs[0])
+ grid.addWidget(self.new_password_label, 1, 0)
+ grid.addWidget(self.new_pw, 1, 1)
+
+ self.confirm_password_label = QLabel(msgs[1])
+ grid.addWidget(self.confirm_password_label, 2, 0)
+ grid.addWidget(self.conf_pw, 2, 1)
+ vbox.addLayout(grid)
+
+ # Password Strength Label
+ if kind != PW_PASSPHRASE:
+ self.pw_strength = QLabel()
+ grid.addWidget(self.pw_strength, 3, 0, 1, 2)
+ self.new_pw.textChanged.connect(self.pw_changed)
+
+ def enable_OK():
+ ok = self.new_pw.text() == self.conf_pw.text()
+ OK_button.setEnabled(ok)
+ self.new_pw.textChanged.connect(enable_OK)
+ self.conf_pw.textChanged.connect(enable_OK)
+ enable_OK()
+
+ self.vbox = vbox
+
+ def title(self):
+ return self.titles[self.kind]
+
+ def layout(self):
+ return self.vbox
+
+ def pw_changed(self):
+ password = self.new_pw.text()
+ if password:
+ colors = {"Weak":"Red", "Medium":"Blue", "Strong":"Green",
+ "Very Strong":"Green"}
+ strength = check_password_strength(password)
+ label = (_("Password Strength") + ": " + "" + strength + "")
+ else:
+ label = ""
+ self.pw_strength.setText(label)
+
+ def old_password(self):
+ if self.kind == PW_CHANGE:
+ return self.pw.text() or None
+ return None
+
+ def new_password(self):
+ pw = self.new_pw.text()
+ # Empty passphrases are fine and returned empty.
+ if pw == "" and self.kind != PW_PASSPHRASE:
+ pw = None
+ return pw
+
+ def clear_password_fields(self):
+ for field in [self.pw, self.new_pw, self.conf_pw]:
+ field.clear()
+
+
+class PasswordLayoutForHW(PasswordLayout):
+
+ def __init__(self, msg, kind, OK_button, wallet=None):
+ PasswordLayout.__init__(self, msg, kind, OK_button, wallet=wallet)
+ self.encrypt_cb = QCheckBox(_('Encrypt wallet file using hardware wallet device'))
+ self.encrypt_cb.setToolTip(_('If you enable this setting, you will need your hardware device to open your wallet.'))
+ self.encrypt_cb.stateChanged.connect(self.on_encrypt_cb)
+ self.grid.addWidget(self.encrypt_cb, 4, 0, 1, 2)
+ self.encrypt_cb.setChecked(wallet.storage.is_encrypted_with_hw_device() if wallet else True)
+
+ def on_encrypt_cb(self, checked):
+ checked = bool(checked)
+ self.new_pw.setVisible(not checked)
+ self.conf_pw.setVisible(not checked)
+ self.new_password_label.setVisible(not checked)
+ self.confirm_password_label.setVisible(not checked)
+
+ def should_encrypt_storage_with_xpub(self):
+ return self.encrypt_cb.isChecked()
+
+
+
+class ChangePasswordDialogBase(WindowModalDialog):
+
+ def __init__(self, parent, wallet):
+ WindowModalDialog.__init__(self, parent)
+ is_encrypted = wallet.has_storage_encryption()
+ OK_button = OkButton(self)
+
+ self.create_password_layout(wallet, is_encrypted, OK_button)
+
+ self.setWindowTitle(self.playout.title())
+ vbox = QVBoxLayout(self)
+ vbox.addLayout(self.playout.layout())
+ vbox.addStretch(1)
+ vbox.addLayout(Buttons(CancelButton(self), OK_button))
+
+ def create_password_layout(self, wallet, is_encrypted, OK_button):
+ raise NotImplementedError()
+
+
+class NewPasswordDialog(WindowModalDialog):
+
+ def __init__(self, parent, msg):
+ self.msg = msg
+ WindowModalDialog.__init__(self, parent)
+ OK_button = OkButton(self)
+ self.playout = PasswordLayout(
+ msg=self.msg,
+ kind=PW_CHANGE,
+ OK_button=OK_button,
+ wallet=None)
+ self.setWindowTitle(self.playout.title())
+ vbox = QVBoxLayout(self)
+ vbox.addLayout(self.playout.layout())
+ vbox.addStretch(1)
+ vbox.addLayout(Buttons(CancelButton(self), OK_button))
+
+ def run(self):
+ try:
+ if not self.exec():
+ return None
+ return self.playout.new_password()
+ finally:
+ self.playout.clear_password_fields()
+
+
+class ChangePasswordDialogForSW(ChangePasswordDialogBase):
+
+ def create_password_layout(self, wallet, is_encrypted, OK_button):
+ if not wallet.has_password():
+ msg = _('Your wallet is not protected.')
+ msg += ' ' + _('Use this dialog to add a password to your wallet.')
+ else:
+ if not is_encrypted:
+ msg = _('Your bitcoins are password protected. However, your wallet file is not encrypted.')
+ else:
+ msg = _('Your wallet is password protected and encrypted.')
+ msg += ' ' + _('Use this dialog to change your password.')
+ self.playout = PasswordLayout(
+ msg=msg,
+ kind=PW_CHANGE,
+ OK_button=OK_button,
+ wallet=wallet)
+
+ def run(self):
+ try:
+ if not self.exec():
+ return False, None, None, None
+ return True, self.playout.old_password(), self.playout.new_password(), True
+ finally:
+ self.playout.clear_password_fields()
+
+
+class ChangePasswordDialogForHW(ChangePasswordDialogBase):
+
+ def __init__(self, parent, wallet):
+ ChangePasswordDialogBase.__init__(self, parent, wallet)
+
+ def create_password_layout(self, wallet, is_encrypted, OK_button):
+ if not is_encrypted:
+ msg = _('Your wallet file is NOT encrypted.')
+ else:
+ if wallet.storage.is_encrypted_with_hw_device():
+ msg = _('Your wallet file is encrypted with your hardware device.')
+ else:
+ msg = _('Your wallet file is password-encrypted.')
+ self.playout = PasswordLayoutForHW(
+ msg=msg,
+ kind=PW_CHANGE,
+ OK_button=OK_button,
+ wallet=wallet)
+
+ def run(self):
+ if not self.exec():
+ return False, None, None, None
+ return True, self.playout.old_password(), self.playout.new_password(), self.playout.should_encrypt_storage_with_xpub()
+
+
+class PasswordDialog(WindowModalDialog):
+
+ def __init__(self, parent=None, msg=None):
+ msg = msg or _('Please enter your password')
+ WindowModalDialog.__init__(self, parent, _("Enter Password"))
+ self.pw = pw = PasswordLineEdit()
+ label = QLabel(msg)
+ label.setWordWrap(True)
+ vbox = QVBoxLayout()
+ vbox.addWidget(label)
+ grid = QGridLayout()
+ grid.setSpacing(8)
+ grid.addWidget(QLabel(_('Password')), 1, 0)
+ grid.addWidget(pw, 1, 1)
+ vbox.addLayout(grid)
+ vbox.addLayout(Buttons(CancelButton(self), OkButton(self)))
+ self.setLayout(vbox)
+ run_hook('password_dialog', pw, grid, 1)
+
+ def run(self):
+ try:
+ if not self.exec():
+ return
+ return self.pw.text()
+ finally:
+ self.pw.clear()
diff --git a/electrum/gui/qt/paytoedit.py b/electrum/gui/qt/paytoedit.py
new file mode 100644
index 000000000000..6bd56145736c
--- /dev/null
+++ b/electrum/gui/qt/paytoedit.py
@@ -0,0 +1,338 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2012 thomasv@gitorious
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from functools import partial
+from typing import Optional, TYPE_CHECKING, Union
+
+from PyQt6.QtCore import Qt, QTimer, QSize, QStringListModel
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtGui import QFontMetrics, QFont, QContextMenuEvent
+from PyQt6.QtWidgets import QTextEdit, QWidget, QLineEdit, QStackedLayout, QCompleter
+
+from electrum.payment_identifier import PaymentIdentifier
+from electrum.logging import Logger
+from electrum.util import EventListener, event_listener
+
+from . import util
+from .util import MONOSPACE_FONT, GenericInputHandler, ColorScheme, add_input_actions_to_context_menu
+
+if TYPE_CHECKING:
+ from .send_tab import SendTab
+
+
+frozen_style = "QWidget {border:none;}"
+normal_style = "QPlainTextEdit { }"
+
+
+class InvalidPaymentIdentifier(Exception):
+ pass
+
+
+class ResizingTextEdit(QTextEdit):
+
+ textReallyChanged = pyqtSignal()
+ resized = pyqtSignal()
+
+ def __init__(self):
+ QTextEdit.__init__(self)
+ self._text = ''
+ self.setAcceptRichText(False)
+ self.textChanged.connect(self.on_text_changed)
+ document = self.document()
+ fontMetrics = QFontMetrics(document.defaultFont())
+ self.fontSpacing = fontMetrics.lineSpacing()
+ margins = self.contentsMargins()
+ documentMargin = document.documentMargin()
+ self.verticalMargins = margins.top() + margins.bottom()
+ self.verticalMargins += self.frameWidth() * 2
+ self.verticalMargins += documentMargin * 2
+ self.heightMin = self.fontSpacing + self.verticalMargins
+ self.heightMax = (self.fontSpacing * 10) + self.verticalMargins
+ self.update_size()
+
+ def on_text_changed(self):
+ # QTextEdit emits spurious textChanged events
+ if self.toPlainText() != self._text:
+ self._text = self.toPlainText()
+ self.textReallyChanged.emit()
+ self.update_size()
+
+ def update_size(self):
+ docLineCount = self.document().lineCount()
+ docHeight = max(3, docLineCount) * self.fontSpacing
+ h = docHeight + self.verticalMargins
+ h = min(max(h, self.heightMin), self.heightMax)
+ self.setMinimumHeight(int(h))
+ self.setMaximumHeight(int(h))
+ self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+ self.verticalScrollBar().setHidden(docHeight + self.verticalMargins < self.heightMax)
+ self.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth)
+ self.resized.emit()
+
+ def sizeHint(self) -> QSize:
+ return QSize(0, self.minimumHeight())
+
+
+class PayToEdit(QWidget, Logger, GenericInputHandler, EventListener):
+ paymentIdentifierChanged = pyqtSignal()
+ textChanged = pyqtSignal()
+
+ def __init__(self, send_tab: 'SendTab'):
+ QWidget.__init__(self, parent=send_tab)
+ Logger.__init__(self)
+ GenericInputHandler.__init__(self)
+
+ self._text = ''
+ self._layout = QStackedLayout()
+ self.setLayout(self._layout)
+
+ self.send_tab = send_tab
+
+ def text_edit_changed():
+ text = self.text_edit.toPlainText()
+ if self._text != text:
+ # sync and emit
+ self._text = text
+ self.line_edit.setText(text)
+ self.textChanged.emit()
+
+ def text_edit_resized():
+ self.update_height()
+
+ def line_edit_changed():
+ text = self.line_edit.text()
+ if self._text != text:
+ # sync and emit
+ self._text = text
+ self.text_edit.setPlainText(text)
+ self.textChanged.emit()
+
+ self.line_edit = QLineEdit()
+ self.line_edit.textChanged.connect(line_edit_changed)
+ self.text_edit = ResizingTextEdit()
+ self.text_edit.setTabChangesFocus(True)
+ self.text_edit.textReallyChanged.connect(text_edit_changed)
+ self.text_edit.resized.connect(text_edit_resized)
+
+ def on_completed(item: str):
+ text = self._completer_contacts[1][self._completer_contacts[0].index(item)]
+ self.try_payment_identifier(text)
+ self.completer.popup().hide()
+
+ self.completer = QCompleter()
+ self.completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
+ self.completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
+ self.completer.setFilterMode(Qt.MatchFlag.MatchContains)
+ self.completer.activated.connect(on_completed)
+
+ self.update_completer()
+
+ self.line_edit.setCompleter(self.completer)
+
+ self.textChanged.connect(self._handle_text_change)
+
+ self._layout.addWidget(self.line_edit)
+ self._layout.addWidget(self.text_edit)
+
+ self.multiline = False
+
+ self._is_paytomany = False
+ self.line_edit.setFont(QFont(MONOSPACE_FONT))
+ self.text_edit.setFont(QFont(MONOSPACE_FONT))
+ self.send_tab = send_tab
+ self.config = send_tab.config
+
+ # button handlers
+ self.on_qr_from_camera_input_btn = partial(
+ self.input_qr_from_camera,
+ config=self.config,
+ allow_multi=False,
+ show_error=self.send_tab.show_error,
+ setText=self.try_payment_identifier,
+ parent=self.send_tab.window,
+ )
+ self.on_qr_from_screenshot_input_btn = partial(
+ self.input_qr_from_screenshot,
+ allow_multi=False,
+ show_error=self.send_tab.show_error,
+ setText=self.try_payment_identifier,
+ )
+ self.on_qr_from_file_input_btn = partial(
+ self.input_qr_from_file,
+ allow_multi=False,
+ config=self.config,
+ show_error=self.send_tab.show_error,
+ setText=self.try_payment_identifier,
+ )
+ self.on_input_file = partial(
+ self.input_file,
+ config=self.config,
+ show_error=self.send_tab.show_error,
+ setText=self.try_payment_identifier,
+ )
+
+ self.text_edit.contextMenuEvent = partial(self.custom_context_menu_event, tl_edit=self.text_edit)
+ self.line_edit.contextMenuEvent = partial(self.custom_context_menu_event, tl_edit=self.line_edit)
+
+ self.edit_timer = QTimer(self)
+ self.edit_timer.setSingleShot(True)
+ self.edit_timer.setInterval(1000)
+ self.edit_timer.timeout.connect(self._on_edit_timer)
+
+ self.payment_identifier = None # type: Optional[PaymentIdentifier]
+
+ self.register_callbacks()
+ self.destroyed.connect(lambda: self.unregister_callbacks())
+
+ def custom_context_menu_event(self, e: 'QContextMenuEvent', *, tl_edit: Union[QTextEdit, QLineEdit]) -> None:
+ m = tl_edit.createStandardContextMenu()
+ m.addSeparator()
+ add_input_actions_to_context_menu(self, m)
+ m.exec(e.globalPos())
+
+ @event_listener
+ def on_event_contacts_updated(self):
+ self.update_completer()
+
+ def update_completer(self):
+ self._completer_contacts = [], []
+ for k, v in self.send_tab.wallet.contacts.items():
+ self._completer_contacts[0].append(f'{v[1]} <{k}>')
+ self._completer_contacts[1].append(k)
+
+ self.completer.setModel(QStringListModel(self._completer_contacts[0]))
+
+ @property
+ def multiline(self):
+ return self._multiline
+
+ @multiline.setter
+ def multiline(self, b: bool) -> None:
+ if b is None:
+ return
+ self._multiline = b
+ self._layout.setCurrentWidget(self.text_edit if b else self.line_edit)
+ self.update_height()
+
+ def update_height(self) -> None:
+ h = self._layout.currentWidget().sizeHint().height()
+ self.setMaximumHeight(h)
+
+ def setText(self, text: str) -> None:
+ if self._text != text:
+ self.line_edit.setText(text)
+ self.text_edit.setText(text)
+
+ def setFocus(self, reason=Qt.FocusReason.OtherFocusReason) -> None:
+ if self.multiline:
+ self.text_edit.setFocus(reason)
+ else:
+ self.line_edit.setFocus(reason)
+
+ def setToolTip(self, tt: str) -> None:
+ self.line_edit.setToolTip(tt)
+ self.text_edit.setToolTip(tt)
+
+ def try_payment_identifier(self, text) -> None:
+ '''set payment identifier only if valid, else exception'''
+ pi = PaymentIdentifier(self.send_tab.wallet, text)
+ if not pi.is_valid():
+ raise InvalidPaymentIdentifier('Invalid payment identifier')
+ self.set_payment_identifier(text)
+
+ def set_payment_identifier(self, text) -> None:
+ if self.payment_identifier and self.payment_identifier.text == text.strip():
+ # no change.
+ return
+
+ self.payment_identifier = PaymentIdentifier(self.send_tab.wallet, text)
+
+ # toggle to multiline if payment identifier is a multiline
+ if self.payment_identifier.is_multiline() and not self._is_paytomany:
+ self.set_paytomany(True)
+
+ # if payment identifier gets set externally, we want to update the edit control
+ # Note: this triggers the change handler, but we shortcut if it's the same payment identifier
+ self.setText(text)
+
+ self.paymentIdentifierChanged.emit()
+
+ def set_paytomany(self, b):
+ self._is_paytomany = b
+ self.multiline = b
+ self.send_tab.paytomany_menu.setChecked(b)
+
+ def toggle_paytomany(self) -> None:
+ self.set_paytomany(not self._is_paytomany)
+
+ def is_paytomany(self):
+ return self._is_paytomany
+
+ def setReadOnly(self, b: bool) -> None:
+ self.line_edit.setReadOnly(b)
+ self.text_edit.setReadOnly(b)
+
+ def isReadOnly(self):
+ return self.line_edit.isReadOnly()
+
+ def setStyleSheet(self, stylesheet: str) -> None:
+ self.line_edit.setStyleSheet(stylesheet)
+ self.text_edit.setStyleSheet(stylesheet)
+
+ def setFrozen(self, b) -> None:
+ self.setReadOnly(b)
+ self.setStyleSheet(ColorScheme.LIGHTBLUE.as_stylesheet(True) if b else '')
+
+ def isFrozen(self):
+ return self.isReadOnly()
+
+ def do_clear(self) -> None:
+ self.set_paytomany(False)
+ self.setText('')
+ self.setToolTip('')
+ self.payment_identifier = None
+
+ def setGreen(self) -> None:
+ self.setStyleSheet(util.ColorScheme.GREEN.as_stylesheet(True))
+
+ def setExpired(self) -> None:
+ self.setStyleSheet(util.ColorScheme.RED.as_stylesheet(True))
+
+ def _handle_text_change(self) -> None:
+ if self.isFrozen():
+ # if editor is frozen, we ignore text changes as they might not be a payment identifier
+ # but a user friendly representation.
+ return
+
+ # pushback timer if timer active or PI needs resolving
+ pi = PaymentIdentifier(self.send_tab.wallet, self._text)
+ if not pi.is_valid() or pi.need_resolve() or self.edit_timer.isActive():
+ self.edit_timer.start()
+ else:
+ self.set_payment_identifier(self._text)
+
+ def _on_edit_timer(self) -> None:
+ if not self.isFrozen():
+ self.set_payment_identifier(self._text)
diff --git a/electrum/gui/qt/plugins_dialog.py b/electrum/gui/qt/plugins_dialog.py
new file mode 100644
index 000000000000..6e65df4c93ab
--- /dev/null
+++ b/electrum/gui/qt/plugins_dialog.py
@@ -0,0 +1,383 @@
+from typing import TYPE_CHECKING, Optional
+from functools import partial
+import shutil
+import os
+
+from PyQt6.QtWidgets import QLabel, QVBoxLayout, QHBoxLayout, QGridLayout, QPushButton, QWidget, QScrollArea, \
+ QFormLayout, QFileDialog, QMenu, QApplication, QMessageBox
+from PyQt6.QtCore import QTimer
+
+from electrum.i18n import _
+from electrum.gui import messages
+from electrum.logging import get_logger
+
+from .util import (WindowModalDialog, Buttons, CloseButton, WWLabel, insert_spaces, MessageBoxMixin,
+ EnterButton, read_QIcon_from_bytes, IconLabel, RunCoroutineDialog, read_QIcon,
+ webopen)
+
+
+if TYPE_CHECKING:
+ from . import ElectrumGui
+ from electrum_ecc import ECPrivkey
+ from electrum.simple_config import SimpleConfig
+ from electrum.plugin import Plugins
+
+
+class PluginDialog(WindowModalDialog):
+
+ def __init__(self, name, metadata, status_button: Optional['PluginStatusButton'], window: 'PluginsDialog'):
+ display_name = metadata.get('fullname', '')
+ author = metadata.get('author', '')
+ description = metadata.get('description', '')
+ requires = metadata.get('requires')
+ version = metadata.get('version')
+ zip_hash = metadata.get('zip_hash_sha256', None)
+ icon_path = metadata.get('icon')
+
+ WindowModalDialog.__init__(self, window, 'Plugin')
+ self.setMinimumSize(400, 250)
+ self.window = window
+ self.metadata = metadata
+ self.plugins = self.window.plugins
+ self.name = name
+ self.status_button = status_button
+ p = self.plugins.get(name) # is enabled
+ vbox = QVBoxLayout(self)
+ name_label = IconLabel(text=display_name, reverse=True)
+ if icon_path:
+ name_label.icon_size = 64
+ icon = read_QIcon_from_bytes(self.plugins.read_file(name, icon_path))
+ name_label.setIcon(icon)
+ vbox.addWidget(name_label)
+ vbox.addStretch()
+ vbox.addWidget(WWLabel(description))
+ vbox.addStretch()
+ form = QFormLayout(None)
+ if author:
+ form.addRow(QLabel(_('Author') + ':'), QLabel(author))
+ if version:
+ form.addRow(QLabel(_('Version') + ':'), QLabel(version))
+ if zip_hash:
+ form.addRow(QLabel('Hash [sha256]:'), WWLabel(insert_spaces(zip_hash, 8)))
+ if requires:
+ msg = '\n'.join(map(lambda x: x[1], requires))
+ form.addRow(QLabel(_('Requires') + ':'), WWLabel(msg))
+ vbox.addLayout(form)
+ vbox.addStretch()
+ close_button = CloseButton(self)
+ close_button.setText(_('Close'))
+ buttons = [close_button]
+ p = self.plugins.get(name)
+ is_enabled = p and p.is_enabled()
+ is_external = self.plugins.is_external(name)
+ if is_external:
+ is_authorized = self.plugins.is_authorized(name)
+ if status_button is not None:
+ # status_button is None when called from add_external_plugin
+ remove_button = QPushButton('')
+ remove_button.clicked.connect(self.do_remove)
+ remove_button.setText(_('Remove'))
+ buttons.insert(0, remove_button)
+ if not is_authorized:
+ auth_button = QPushButton('Install')
+ auth_button.clicked.connect(self.do_authorize)
+ buttons.insert(0, auth_button)
+ else:
+ toggle_button = QPushButton('')
+ toggle_button.setText(_('Disable') if is_enabled else _('Enable'))
+ toggle_button.clicked.connect(self.do_toggle)
+ buttons.insert(0, toggle_button)
+ # add settings button
+ if p and p.requires_settings() and p.is_enabled():
+ settings_button = EnterButton(
+ _('Settings'),
+ partial(p.settings_dialog, self))
+ buttons.insert(1, settings_button)
+ # add buttons
+ vbox.addLayout(Buttons(*buttons))
+
+ def do_toggle(self):
+ if not self.plugins.is_available(self.name):
+ msg = "\n".join([
+ _('This plugin requires installation of additional dependencies.'),
+ _('For Electrum to recognize external packages, you need to run it from source.')
+ ])
+ self.window.show_message(msg)
+ return
+
+ self.close()
+ self.window.do_toggle(self.name, self.status_button)
+
+ def do_remove(self):
+ self.window.uninstall_plugin(self.name)
+ self.close()
+
+ def do_authorize(self):
+ assert not self.plugins.is_authorized(self.name)
+ privkey = self.window.get_plugins_privkey()
+ if not privkey:
+ return
+ filename = self.plugins.zip_plugin_path(self.name)
+ self.window.plugins.authorize_plugin(self.name, filename, privkey)
+ self.window.plugins.enable(self.name)
+ d = self.plugins.get_metadata(self.name)
+ if details := d.get('registers_keystore'):
+ self.plugins.register_keystore(self.name, details)
+ if self.status_button:
+ self.status_button.update()
+ self.accept()
+
+
+class PluginStatusButton(QPushButton):
+
+ def __init__(self, window: 'PluginsDialog', name: str):
+ QPushButton.__init__(self, '')
+ self.window = window
+ self.plugins = window.plugins
+ self.name = name
+ self.clicked.connect(self.show_plugin_dialog)
+ self.update()
+
+ def show_plugin_dialog(self):
+ metadata = self.plugins.descriptions[self.name]
+ d = PluginDialog(self.name, metadata, self, self.window)
+ d.exec()
+
+ def update(self):
+ from .util import ColorScheme
+ p = self.plugins.get(self.name)
+ plugin_is_loaded = p is not None
+ enabled = not plugin_is_loaded or (plugin_is_loaded and p.can_user_disable())
+ self.setEnabled(enabled)
+ if p is not None and p.is_enabled():
+ text, color = _('Enabled'), ColorScheme.BLUE
+ else:
+ text, color = _('Disabled'), ColorScheme.RED
+ self.setStyleSheet(color.as_stylesheet())
+ self.setText(text)
+
+
+class PluginsDialog(WindowModalDialog, MessageBoxMixin):
+ _logger = get_logger(__name__)
+
+ def __init__(self, config: 'SimpleConfig', plugins: 'Plugins', *, gui_object: Optional['ElectrumGui'] = None):
+ WindowModalDialog.__init__(self, None, _('Electrum Plugins'))
+ self.gui_object = gui_object
+ self.config = config
+ self.plugins = plugins
+ vbox = QVBoxLayout(self)
+ scroll = QScrollArea()
+ scroll.setEnabled(True)
+ scroll.setWidgetResizable(True)
+ scroll.setMinimumSize(400, 250)
+ scroll_w = QWidget()
+ scroll.setWidget(scroll_w)
+ self.grid = QGridLayout()
+ self.grid.setColumnStretch(0, 1)
+ scroll_w.setLayout(self.grid)
+ vbox.addWidget(scroll)
+ add_button = QPushButton(_('Add'))
+ add_button.setMinimumWidth(40) # looks better on windows, no difference on linux
+ add_button.clicked.connect(self.add_plugin_dialog)
+ website_button = QPushButton(read_QIcon('globe.png'), _('Help'))
+ website_button.setToolTip(_('Visit plugins website'))
+ website_button.clicked.connect(lambda: webopen('https://plugins.electrum.org/'))
+ hbox = QHBoxLayout()
+ hbox.addWidget(website_button)
+ hbox.addStretch(1)
+ hbox.addWidget(add_button)
+ hbox.addWidget(CloseButton(self))
+ vbox.addLayout(hbox)
+ self.show_list()
+
+ def get_plugins_privkey(self) -> Optional['ECPrivkey']:
+ pubkey, salt = self.plugins.get_pubkey_bytes()
+ if not pubkey:
+ self.init_plugins_password()
+ return None
+ # ask for url and password, same window
+ pw = self.password_dialog(msg=messages.MSG_THIRD_PARTY_PLUGIN_WARNING)
+ if not pw:
+ return None
+ privkey = self.plugins.derive_privkey(pw, salt)
+ if pubkey != privkey.get_public_key_bytes():
+ keyfile_path, _keyfile_help = self.plugins.get_keyfile_path(None)
+
+ while True:
+ exit_dialog = True
+ auto_reset_btn = QPushButton(_('Try Auto-Reset'))
+ def on_try_auto_reset_clicked():
+ nonlocal exit_dialog
+ if not self.plugins.try_auto_key_reset():
+ self.show_error(_("Auto-Reset not possible. Delete the file manually."))
+ exit_dialog = False
+ else:
+ self.show_message(_("Auto-Reset successful. You can now setup a new password."))
+ auto_reset_btn.clicked.connect(on_try_auto_reset_clicked)
+
+ buttons = [
+ QMessageBox.StandardButton.Ok,
+ (auto_reset_btn, QMessageBox.ButtonRole.ActionRole, 0),
+ ]
+ if self.show_error(
+ ''.join([
+ _('Incorrect password.'), '\n\n',
+ _('Your plugin authorization password is required to install plugins.'), ' ',
+ _('If you need to reset it, remove the following file:'), '\n\n',
+ keyfile_path
+ ]),
+ buttons=buttons
+ ) or exit_dialog:
+ break
+
+ return None
+ return privkey
+
+ def init_plugins_password(self):
+ from .password_dialog import NewPasswordDialog
+ msg = ' '.join([
+ _('In order to install third-party plugins, you need to choose a plugin authorization password.'),
+ _('Its purpose is to prevent unauthorized users (or malware) from installing plugins.'),
+ ])
+ d = NewPasswordDialog(self, msg=msg)
+ pw = d.run()
+ if not pw:
+ return
+ key_hex = self.plugins.create_new_key(pw)
+ keyfile_path, keyfile_help = self.plugins.get_keyfile_path(key_hex)
+ msg = '\n\n'.join([
+ _('Your plugins key is:'), key_hex,
+ _('This key has been copied to your clipboard. Please save it in:'),
+ keyfile_path,
+ keyfile_help,
+ '',
+ ])
+ clipboard = QApplication.clipboard()
+ clipboard.setText(key_hex)
+
+ while True:
+ exit_dialog = True
+ # the button has to be recreated inside the loop, as qt destroys it when the dialog is closed
+ auto_setup_btn = QPushButton(_('Try Auto-Setup'))
+ def on_auto_setup_clicked():
+ nonlocal exit_dialog
+ if not self.plugins.try_auto_key_setup(key_hex):
+ self.show_error(_("Auto-Setup not possible. Try the manual setup."))
+ exit_dialog = False
+ else:
+ self.show_message(_("Auto-Setup successful. You can now install plugins."))
+ auto_setup_btn.clicked.connect(on_auto_setup_clicked)
+
+ # on windows, the auto-setup button is shown right of the ok button,
+ # apparently due to OS conventions
+ buttons = [
+ (auto_setup_btn, QMessageBox.ButtonRole.ActionRole, 0),
+ QMessageBox.StandardButton.Ok,
+ ]
+ if self.show_message(msg, buttons=buttons) or exit_dialog:
+ break
+
+ def add_plugin_dialog(self):
+ pubkey, salt = self.plugins.get_pubkey_bytes()
+ if not pubkey:
+ self.init_plugins_password()
+ return
+ filename, __ = QFileDialog.getOpenFileName(self, _("Select your plugin zipfile"), "", "*.zip")
+ if not filename:
+ return
+ plugins_dir = self.plugins.get_external_plugin_dir()
+ path = os.path.join(plugins_dir, os.path.basename(filename))
+ if os.path.exists(path):
+ self.show_warning(_('Plugin already installed.'))
+ return
+ try:
+ shutil.copyfile(filename, path)
+ except OSError as e:
+ self.show_error(_("Could not copy plugin file {} into directory {}:\n\n{}").format(
+ filename,
+ path,
+ str(e)
+ ))
+ return
+ self._try_add_external_plugin_from_path(path)
+
+ def _try_add_external_plugin_from_path(self, path: str):
+ try:
+ success = self.add_external_plugin(path)
+ except Exception as e:
+ self._logger.exception("")
+ self.show_error(f"{e}")
+ success = False
+ if not success:
+ try:
+ os.unlink(path)
+ except FileNotFoundError:
+ self._logger.debug("", exc_info=True)
+
+ def add_external_plugin(self, path):
+ manifest = self.plugins.read_manifest(path)
+ name = manifest['name']
+ self.plugins.external_plugin_metadata[name] = manifest
+ d = PluginDialog(name, manifest, None, self)
+ if not d.exec():
+ self.plugins.external_plugin_metadata.pop(name)
+ return False
+ if self.gui_object:
+ self.gui_object.reload_windows()
+ self.show_list()
+ return True
+
+ def show_list(self):
+ descriptions = self.plugins.descriptions
+ descriptions = sorted(descriptions.items())
+ grid = self.grid
+ # clear existing items
+ for i in reversed(range(grid.count())):
+ grid.itemAt(i).widget().setParent(None)
+ # populate
+ i = 0
+ for name, metadata in descriptions:
+ i += 1
+ if self.plugins.is_internal(name) and self.plugins.is_auto_loaded(name):
+ continue
+ display_name = metadata.get('fullname')
+ if not display_name:
+ continue
+ label = IconLabel(text=display_name, reverse=True)
+ icon_path = metadata.get('icon')
+ if icon_path:
+ icon = read_QIcon_from_bytes(self.plugins.read_file(name, icon_path))
+ label.setIcon(icon)
+ label.status_button = PluginStatusButton(self, name)
+ grid.addWidget(label, i, 0)
+ grid.addWidget(label.status_button, i, 1)
+ # add stretch
+ grid.setRowStretch(i + 1, 1)
+
+ def do_toggle(self, name, status_button):
+ p = self.plugins.get(name)
+ is_enabled = p and p.is_enabled()
+ if is_enabled:
+ self.plugins.disable(name)
+ else:
+ self.plugins.enable(name)
+ if status_button:
+ status_button.update()
+ if self.gui_object:
+ self.gui_object.reload_windows()
+ self.bring_to_front()
+
+ def uninstall_plugin(self, name):
+ if not self.question(_('Remove plugin \'{}\'?').format(name)):
+ return
+ self.plugins.uninstall(name)
+ if self.gui_object:
+ self.gui_object.reload_windows()
+ self.show_list()
+ self.bring_to_front()
+
+ def bring_to_front(self):
+ def _bring_self_to_front():
+ self.activateWindow()
+ self.setFocus()
+ QTimer.singleShot(100, _bring_self_to_front)
diff --git a/electrum/gui/qt/qrcodewidget.py b/electrum/gui/qt/qrcodewidget.py
new file mode 100644
index 000000000000..823a9d44b643
--- /dev/null
+++ b/electrum/gui/qt/qrcodewidget.py
@@ -0,0 +1,152 @@
+from typing import Optional
+
+import qrcode
+import qrcode.exceptions
+
+import PyQt6.QtGui as QtGui
+from PyQt6.QtCore import QRect
+from PyQt6.QtWidgets import QApplication, QVBoxLayout, QHBoxLayout, QPushButton, QWidget
+
+from electrum.i18n import _
+from electrum.simple_config import SimpleConfig
+from electrum.gui.common_qt.util import draw_qr
+
+from .util import WindowModalDialog, WWLabel, getSaveFileName
+
+
+class QrCodeDataOverflow(qrcode.exceptions.DataOverflowError):
+ pass
+
+
+class QRCodeWidget(QWidget):
+
+ MIN_BOXSIZE = 2 # min size in pixels of single black/white unit box of the qr code
+
+ def __init__(self, data=None, *, manual_size: bool = False):
+ QWidget.__init__(self)
+ self.data = None
+ self.qr = None
+ self._framesize = None # type: Optional[int]
+ self._manual_size = manual_size
+ self.setData(data)
+
+ def setData(self, data):
+ if data:
+ qr = qrcode.QRCode(
+ error_correction=qrcode.constants.ERROR_CORRECT_L,
+ border=1,
+ )
+ try:
+ qr.add_data(data)
+ qr_matrix = qr.get_matrix() # test that data fits in QR code
+ except (ValueError, qrcode.exceptions.DataOverflowError) as e:
+ raise QrCodeDataOverflow() from e
+ self.qr = qr
+ self.data = data
+ if not self._manual_size:
+ k = len(qr_matrix)
+ size = min(k * 5, 150 + k * self.MIN_BOXSIZE)
+ self.setMinimumSize(size, size)
+ else:
+ self.qr = None
+ self.data = None
+
+ self.update()
+
+ def paintEvent(self, e):
+ if not self.data:
+ return
+ draw_qr(
+ qr=self.qr,
+ paint_device=self,
+ is_enabled=self.isEnabled(),
+ min_boxsize=self.MIN_BOXSIZE,
+ )
+
+ def grab(self) -> QtGui.QPixmap:
+ """Overrides QWidget.grab to only include the QR code itself,
+ excluding horizontal/vertical stretch.
+ """
+ fsize = self._framesize
+ if fsize is None:
+ fsize = -1
+ rect = QRect(0, 0, fsize, fsize)
+ return QWidget.grab(self, rect)
+
+
+class QRDialog(WindowModalDialog):
+
+ def __init__(
+ self,
+ *,
+ data,
+ parent=None,
+ title="",
+ show_text=False,
+ help_text=None,
+ show_copy_text_btn=False,
+ config: SimpleConfig,
+ ):
+ WindowModalDialog.__init__(self, parent, title)
+ self.config = config
+
+ vbox = QVBoxLayout()
+
+ qrw = QRCodeWidget(data, manual_size=False)
+ vbox.addWidget(qrw, 1)
+
+ help_text = data if show_text else help_text
+ if help_text:
+ text_label = WWLabel()
+ text_label.setText(help_text)
+ vbox.addWidget(text_label)
+ hbox = QHBoxLayout()
+ hbox.addStretch(1)
+
+ def print_qr():
+ filename = getSaveFileName(
+ parent=self,
+ title=_("Select where to save file"),
+ filename="qrcode.png",
+ config=self.config,
+ )
+ if not filename:
+ return
+ p = qrw.grab()
+ p.save(filename, 'png')
+ self.show_message(_("QR code saved to file") + " " + filename)
+
+ def copy_image_to_clipboard():
+ p = qrw.grab()
+ QApplication.clipboard().setPixmap(p)
+ self.show_message(_("QR code copied to clipboard"))
+
+ def copy_text_to_clipboard():
+ QApplication.clipboard().setText(data)
+ self.show_message(_("Text copied to clipboard"))
+
+ b = QPushButton(_("Copy Image"))
+ hbox.addWidget(b)
+ b.clicked.connect(copy_image_to_clipboard)
+
+ if show_copy_text_btn:
+ b = QPushButton(_("Copy Text"))
+ hbox.addWidget(b)
+ b.clicked.connect(copy_text_to_clipboard)
+
+ b = QPushButton(_("Save"))
+ hbox.addWidget(b)
+ b.clicked.connect(print_qr)
+
+ b = QPushButton(_("Close"))
+ hbox.addWidget(b)
+ b.clicked.connect(self.accept)
+ b.setDefault(True)
+
+ vbox.addLayout(hbox)
+ self.setLayout(vbox)
+
+ # note: the word-wrap on the text_label is causing layout sizing issues.
+ # see https://stackoverflow.com/a/25661985 and https://bugreports.qt.io/browse/QTBUG-37673
+ # workaround:
+ self.setMinimumSize(self.sizeHint())
diff --git a/electrum/gui/qt/qrreader/__init__.py b/electrum/gui/qt/qrreader/__init__.py
new file mode 100644
index 000000000000..914a2a178354
--- /dev/null
+++ b/electrum/gui/qt/qrreader/__init__.py
@@ -0,0 +1,216 @@
+# Copyright (C) 2021 The Electrum developers
+# Distributed under the MIT software license, see the accompanying
+# file LICENCE or http://www.opensource.org/licenses/mit-license.php
+#
+# We have two toolchains to scan qr codes:
+# 1. access camera via QtMultimedia, take picture, feed picture to zbar
+# 2. let zbar handle whole flow (including accessing the camera)
+#
+# notes:
+# - zbar needs to be compiled with platform-dependent extra config options to be able
+# to access the camera
+# - zbar fails to access the camera on macOS
+# - qtmultimedia seems to support more cameras on Windows than zbar
+# - qtmultimedia is often not packaged with PyQt
+# in particular, on debian, you need both "python3-pyqt6" and "python3-pyqt6.qtmultimedia"
+# - older versions of qtmultimedia don't seem to work reliably
+#
+# Considering the above, we use QtMultimedia for Windows and macOS, as there
+# most users run our binaries where we can make sure the packaged versions work well.
+# On Linux where many people run from source, we use zbar.
+#
+# Note: this module is safe to import on all platforms.
+
+import sys
+from typing import Callable, Optional, TYPE_CHECKING, Mapping, Sequence
+
+from PyQt6.QtWidgets import QMessageBox, QWidget
+from PyQt6.QtGui import QImage, QPainter, QColor
+from PyQt6.QtCore import QRect, QCoreApplication
+from PyQt6 import QtCore
+
+from electrum.i18n import _
+from electrum.util import UserFacingException
+from electrum.logging import get_logger
+from electrum.qrreader import get_qr_reader, QrCodeResult, MissingQrDetectionLib
+
+from electrum.gui.qt.util import MessageBoxMixin, custom_message_box
+
+
+if TYPE_CHECKING:
+ from electrum.simple_config import SimpleConfig
+
+
+_logger = get_logger(__name__)
+
+
+def scan_qrcode_from_camera(
+ *,
+ parent: Optional[QWidget],
+ config: 'SimpleConfig',
+ callback: Callable[[bool, str, Optional[str]], None],
+) -> None:
+ """Scans QR code using camera. It handles requesting camera access permission from the OS if needed."""
+ assert parent is None or isinstance(parent, QWidget), f"parent should be a QWidget, not {parent!r}"
+ def do_scan():
+ _scan_qrcode_from_camera(parent=parent, config=config, callback=callback)
+
+ if _has_camera_permission():
+ do_scan()
+ else:
+ # Request permission now. This is only a thing on macOS atm.
+ # Note: this assumes we are running on the main thread. Permissions can only be requested from the main thread.
+ app = QCoreApplication.instance()
+ app.requestPermission(QtCore.QCameraPermission(), lambda _x: do_scan())
+
+
+def scan_qr_from_image(image: QImage) -> Sequence[QrCodeResult]:
+ """Might raise exception: MissingQrDetectionLib."""
+ qr_reader = get_qr_reader()
+
+ for attempt in range(4):
+ image_y800 = image.convertToFormat(QImage.Format.Format_Grayscale8)
+ res = qr_reader.read_qr_code(
+ image_y800.constBits().__int__(),
+ image_y800.sizeInBytes(),
+ image_y800.bytesPerLine(),
+ image_y800.width(),
+ image_y800.height(),
+ )
+ if res:
+ break
+ # zbar doesn't like qr codes that are too large in relation to the whole image
+ image = _reduce_qr_code_density(image)
+ return res
+
+def _reduce_qr_code_density(image: QImage) -> QImage:
+ """ Reduces the size of the qr code relative to the whole image. """
+ new_image = QImage(image.width(), image.height(), QImage.Format.Format_RGB32)
+ new_image.fill(QColor(255, 255, 255)) # Fill white
+
+ painter = QPainter(new_image)
+ source_rect = QRect(0, 0, image.width(), image.height())
+ target_rect = QRect(0, 0, int(image.width() * 0.75), int(image.height() * 0.75))
+ painter.drawImage(target_rect, image, source_rect)
+ painter.end()
+
+ return new_image
+
+def find_system_cameras() -> Mapping[str, str]:
+ """Returns a camera_description -> camera_path map."""
+ if sys.platform == 'darwin' or sys.platform in ('windows', 'win32'):
+ try:
+ from .qtmultimedia import find_system_cameras
+ except (ImportError, RuntimeError) as e:
+ _logger.exception('error importing .qtmultimedia')
+ return {}
+ else:
+ return find_system_cameras()
+ else: # desktop Linux and similar
+ from electrum import qrscanner
+ return qrscanner.find_system_cameras()
+
+
+# --- Internals below (not part of external API)
+
+def _scan_qrcode_using_zbar(
+ *,
+ parent: Optional[QWidget],
+ config: 'SimpleConfig',
+ callback: Callable[[bool, str, Optional[str]], None],
+) -> None:
+ from electrum import qrscanner
+ data = None
+ try:
+ data = qrscanner.scan_barcode(config.get_video_device())
+ except UserFacingException as e:
+ success = False
+ error = str(e)
+ except BaseException as e:
+ _logger.exception('camera error')
+ success = False
+ error = repr(e)
+ else:
+ success = True
+ error = ""
+ if data is None:
+ # probably user cancelled
+ success = False
+ callback(success, error, data)
+
+
+# Use a global to prevent multiple QR dialogs created simultaneously
+_qr_dialog = None
+
+
+def _scan_qrcode_using_qtmultimedia(
+ *,
+ parent: Optional[QWidget],
+ config: 'SimpleConfig',
+ callback: Callable[[bool, str, Optional[str]], None],
+) -> None:
+ try:
+ from .qtmultimedia import QrReaderCameraDialog, CameraError
+ except (ImportError, RuntimeError) as e:
+ icon = QMessageBox.Icon.Warning
+ title = _("QR Reader Error")
+ message = _("QR reader failed to load. This may happen if "
+ "you are using an older version of PyQt.") + "\n\n" + str(e)
+ _logger.exception(message)
+ if isinstance(parent, MessageBoxMixin):
+ parent.msg_box(title=title, text=message, icon=icon, parent=None)
+ else:
+ custom_message_box(title=title, text=message, icon=icon, parent=parent)
+ return
+
+ global _qr_dialog
+ if _qr_dialog:
+ _logger.warning("QR dialog is already presented, ignoring.")
+ return
+ _qr_dialog = None
+ try:
+ _qr_dialog = QrReaderCameraDialog(parent=parent, config=config)
+
+ def _on_qr_reader_finished(success: bool, error: str, data):
+ global _qr_dialog
+ if _qr_dialog:
+ _qr_dialog.deleteLater()
+ _qr_dialog = None
+ callback(success, error, data)
+
+ _qr_dialog.qr_finished.connect(_on_qr_reader_finished)
+ _qr_dialog.start_scan(config.get_video_device())
+ except (MissingQrDetectionLib, CameraError) as e:
+ _qr_dialog = None
+ callback(False, str(e), None)
+ except Exception as e:
+ _logger.exception('camera error')
+ _qr_dialog = None
+ callback(False, repr(e), None)
+
+
+def _scan_qrcode_from_camera(
+ *,
+ parent: Optional[QWidget],
+ config: 'SimpleConfig',
+ callback: Callable[[bool, str, Optional[str]], None],
+) -> None:
+ """Scans QR code using camera."""
+ assert parent is None or isinstance(parent, QWidget), f"parent should be a QWidget, not {parent!r}"
+ if not _has_camera_permission():
+ callback(False, _("Missing camera permission."), None)
+ return
+ if sys.platform == 'darwin' or sys.platform in ('windows', 'win32'):
+ _scan_qrcode_using_qtmultimedia(parent=parent, config=config, callback=callback)
+ else: # desktop Linux and similar
+ _scan_qrcode_using_zbar(parent=parent, config=config, callback=callback)
+
+
+def _has_camera_permission() -> bool:
+ if not hasattr(QtCore, "QCameraPermission"): # requires Qt 6.5+
+ _logger.info(f"QtCore does not support QCameraPermission. This requires Qt 6.5+")
+ return True # hope for the best
+ app = QCoreApplication.instance()
+ permission_status = app.checkPermission(QtCore.QCameraPermission())
+ return permission_status == QtCore.Qt.PermissionStatus.Granted
+
diff --git a/electrum/gui/qt/qrreader/qtmultimedia/__init__.py b/electrum/gui/qt/qrreader/qtmultimedia/__init__.py
new file mode 100644
index 000000000000..220f974a2cce
--- /dev/null
+++ b/electrum/gui/qt/qrreader/qtmultimedia/__init__.py
@@ -0,0 +1,44 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019 Axel Gembe
+# Copyright (c) 2024 The Electrum developers
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+#
+# -----
+#
+# Note: This module is risky to import. At the very least, ImportError and
+# RuntimeError needs to be handled at import time!
+
+from typing import Mapping
+
+from .camera_dialog import (QrReaderCameraDialog, CameraError, NoCamerasFound,
+ get_camera_path)
+from .validator import (QrReaderValidatorResult, AbstractQrReaderValidator,
+ QrReaderValidatorCounting, QrReaderValidatorColorizing,
+ QrReaderValidatorStrong, QrReaderValidatorCounted)
+
+
+def find_system_cameras() -> Mapping[str, str]:
+ """Returns a camera_description -> camera_path map."""
+ from PyQt6.QtMultimedia import QMediaDevices
+ system_cameras = QMediaDevices.videoInputs()
+ return {cam.description(): get_camera_path(cam) for cam in system_cameras}
diff --git a/electrum/gui/qt/qrreader/qtmultimedia/camera_dialog.py b/electrum/gui/qt/qrreader/qtmultimedia/camera_dialog.py
new file mode 100644
index 000000000000..003b05377456
--- /dev/null
+++ b/electrum/gui/qt/qrreader/qtmultimedia/camera_dialog.py
@@ -0,0 +1,330 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019 Axel Gembe
+# Copyright (c) 2024 The Electrum developers
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import time
+import math
+import sys
+import os
+from typing import List, Optional
+
+from PyQt6.QtMultimedia import QMediaDevices, QCamera, QMediaCaptureSession, QCameraDevice
+from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QCheckBox, QPushButton, QLabel, QWidget
+from PyQt6.QtGui import QImage, QPixmap
+from PyQt6.QtCore import QSize, QRect, Qt, pyqtSignal, PYQT_VERSION
+
+from electrum.simple_config import SimpleConfig
+from electrum.i18n import _
+from electrum.qrreader import get_qr_reader, QrCodeResult, MissingQrDetectionLib
+from electrum.logging import Logger
+
+from electrum.gui.qt.util import MessageBoxMixin, FixedAspectRatioLayout, ImageGraphicsEffect
+
+from .video_widget import QrReaderVideoWidget
+from .video_overlay import QrReaderVideoOverlay
+from .video_surface import QrReaderVideoSurface
+from .crop_blur_effect import QrReaderCropBlurEffect
+from .validator import AbstractQrReaderValidator, QrReaderValidatorCounted, QrReaderValidatorResult
+
+
+class CameraError(RuntimeError):
+ ''' Base class of the camera-related error conditions. '''
+
+class NoCamerasFound(CameraError):
+ ''' Raised by start_scan if no usable cameras were found. Interested
+ code can catch this specific exception.'''
+
+
+def get_camera_path(cam: 'QCameraDevice') -> str:
+ return bytes(cam.id()).decode('ascii')
+
+
+class QrReaderCameraDialog(Logger, MessageBoxMixin, QDialog):
+ """
+ Dialog for reading QR codes from a camera
+ """
+
+ # Try to crop so we have minimum 512 dimensions
+ SCAN_SIZE: int = 512
+
+ qr_finished = pyqtSignal(bool, str, object)
+
+ def __init__(self, parent: Optional[QWidget], *, config: SimpleConfig):
+ ''' Note: make sure parent is a "top_level_window()" as per
+ MessageBoxMixin API else bad things can happen on macOS. '''
+ QDialog.__init__(self, parent=parent)
+ Logger.__init__(self)
+
+ self.validator: AbstractQrReaderValidator = None
+ self.frame_id: int = 0
+ self.qr_crop: QRect = None
+ self.qrreader_res: List[QrCodeResult] = []
+ self.validator_res: QrReaderValidatorResult = None
+ self.last_stats_time: float = 0.0
+ self.frame_counter: int = 0
+ self.qr_frame_counter: int = 0
+ self.last_qr_scan_ts: float = 0.0
+ self.camera: QCamera = None
+ self.media_capture_session: QMediaCaptureSession = None
+ self._error_message: str = None
+ self._ok_done: bool = False
+ self.camera_sc_conn = None
+ self.resolution: QSize = None
+
+ self.config = config
+
+ # Try to get the QR reader for this system
+ self.qrreader = get_qr_reader()
+
+ # Set up the window, add the maximize button
+ flags = self.windowFlags()
+ flags = flags | Qt.WindowType.WindowMaximizeButtonHint
+ self.setWindowFlags(flags)
+ self.setWindowTitle(_("Scan QR Code"))
+ self.setWindowModality(Qt.WindowModality.WindowModal if parent else Qt.WindowModality.ApplicationModal)
+
+ # Create video widget and fixed aspect ratio layout to contain it
+ self.video_widget = QrReaderVideoWidget()
+ self.video_overlay = QrReaderVideoOverlay()
+ self.video_layout = FixedAspectRatioLayout()
+ self.video_layout.addWidget(self.video_widget)
+ self.video_layout.addWidget(self.video_overlay)
+
+ # Create root layout and add the video widget layout to it
+ vbox = QVBoxLayout()
+ self.setLayout(vbox)
+ vbox.setContentsMargins(0, 0, 0, 0)
+ vbox.addLayout(self.video_layout)
+
+ # Create a layout for the controls
+ controls_layout = QHBoxLayout()
+ controls_layout.addStretch(2)
+ controls_layout.setContentsMargins(10, 10, 10, 10)
+ controls_layout.setSpacing(10)
+ vbox.addLayout(controls_layout)
+
+ # Flip horizontally checkbox with default coming from global config
+ self.flip_x = QCheckBox()
+ self.flip_x.setText(_("&Flip horizontally"))
+ self.flip_x.setChecked(self.config.QR_READER_FLIP_X)
+ self.flip_x.stateChanged.connect(self._on_flip_x_changed)
+ controls_layout.addWidget(self.flip_x)
+
+ close_but = QPushButton(_("&Close"))
+ close_but.clicked.connect(self.reject)
+ controls_layout.addWidget(close_but)
+
+ # Create the video surface and receive events when new frames arrive
+ self.video_surface = QrReaderVideoSurface(self)
+ self.video_surface.frame_available.connect(self._on_frame_available)
+
+ # Create the crop blur effect
+ self.crop_blur_effect = QrReaderCropBlurEffect(self)
+ self.image_effect = ImageGraphicsEffect(self, self.crop_blur_effect)
+
+
+ # Note these should stay as queued connections because we use the idiom
+ # self.reject() and self.accept() in this class to kill the scan --
+ # and we do it from within callback functions. If you don't use
+ # queued connections here, bad things can happen.
+ self.finished.connect(self._boilerplate_cleanup, Qt.ConnectionType.QueuedConnection)
+ self.finished.connect(self._on_finished, Qt.ConnectionType.QueuedConnection)
+
+ def _on_flip_x_changed(self, _state: int):
+ self.config.QR_READER_FLIP_X = self.flip_x.isChecked()
+
+ @staticmethod
+ def _get_crop(resolution: QSize, scan_size: int) -> QRect:
+ """
+ Returns a QRect that is scan_size x scan_size in the middle of the resolution
+ """
+ scan_pos_x = (resolution.width() - scan_size) // 2
+ scan_pos_y = (resolution.height() - scan_size) // 2
+ return QRect(scan_pos_x, scan_pos_y, scan_size, scan_size)
+
+ def start_scan(self, device: str = ''):
+ """
+ Scans a QR code from the given camera device.
+ If no QR code is found the returned string will be empty.
+ If the camera is not found or can't be opened NoCamerasFound will be raised.
+ """
+
+ self.validator = QrReaderValidatorCounted()
+
+ device_info = None
+
+ for camera in QMediaDevices.videoInputs():
+ if get_camera_path(camera) == device:
+ device_info = camera
+ break
+
+ if not device_info:
+ self.logger.info('Failed to open selected camera, trying to use default camera')
+ device_info = QMediaDevices.defaultVideoInput()
+
+ if not device_info or device_info.isNull():
+ raise NoCamerasFound(_("Cannot start QR scanner, no usable camera found."))
+
+ self._init_stats()
+ self.qrreader_res = []
+ self.validator_res = None
+ self._ok_done = False
+ self._error_message = None
+
+ if self.camera:
+ self.logger.info("Warning: start_scan already called for this instance.")
+
+ self.camera = QCamera(device_info)
+ self.camera.start()
+ self.camera.errorOccurred.connect(self._on_camera_error) # log the errors we get, if any, for debugging
+
+ self.media_capture_session = QMediaCaptureSession()
+ self.media_capture_session.setCamera(self.camera)
+ self.media_capture_session.setVideoSink(self.video_surface)
+
+ self.open()
+
+ def _set_resolution(self, resolution: QSize):
+ self.resolution = resolution
+ self.qr_crop = self._get_crop(resolution, self.SCAN_SIZE)
+
+ # Initialize the video widget
+ #self.video_widget.setMinimumSize(resolution) # <-- on macOS this makes it fixed size for some reason.
+ self.resize(720, 540)
+ self.video_overlay.set_crop(self.qr_crop)
+ self.video_overlay.set_resolution(resolution)
+ self.video_layout.set_aspect_ratio(resolution.width() / resolution.height())
+
+ # Set up the crop blur effect
+ self.crop_blur_effect.setCrop(self.qr_crop)
+
+ def _on_camera_error(self, error: QCamera.Error, error_str: str):
+ self.logger.info(f"QCamera error: {error}. {error_str}")
+
+ def accept(self):
+ self._ok_done = True # immediately blocks further processing
+ super().accept()
+
+ def reject(self):
+ self._ok_done = True # immediately blocks further processing
+ super().reject()
+
+ def _boilerplate_cleanup(self):
+ self._close_camera()
+ if self.isVisible():
+ self.close()
+
+ def _close_camera(self):
+ if self.camera:
+ self.camera.stop()
+ self.camera = None
+
+ def _on_finished(self, code):
+ res = ( (code == QDialog.DialogCode.Accepted
+ and self.validator_res and self.validator_res.accepted
+ and self.validator_res.simple_result)
+ or '' )
+
+ self.validator = None
+
+ self.logger.info(f'closed {res}')
+
+ self.qr_finished.emit(code == QDialog.DialogCode.Accepted, self._error_message, res)
+
+ def _on_frame_available(self, frame: QImage):
+ if self._ok_done:
+ return
+
+ self.frame_id += 1
+
+ self._set_resolution(frame.size())
+
+ flip_x = self.flip_x.isChecked()
+
+ # Only QR scan every QR_SCAN_PERIOD secs
+ qr_scanned = time.time() - self.last_qr_scan_ts >= self.qrreader.interval()
+ if qr_scanned:
+ self.last_qr_scan_ts = time.time()
+ # Crop the frame so we only scan a SCAN_SIZE rect
+ frame_cropped = frame.copy(self.qr_crop)
+
+ # Convert to Y800 / GREY FourCC (single 8-bit channel)
+ # This creates a copy, so we don't need to keep the frame around anymore
+ frame_y800 = frame_cropped.convertToFormat(QImage.Format.Format_Grayscale8)
+
+ # Read the QR codes from the frame
+ self.qrreader_res = self.qrreader.read_qr_code(
+ frame_y800.constBits().__int__(),
+ frame_y800.sizeInBytes(),
+ frame_y800.bytesPerLine(),
+ frame_y800.width(),
+ frame_y800.height(),
+ self.frame_id,
+ )
+
+ # Call the validator to see if the scanned results are acceptable
+ self.validator_res = self.validator.validate_results(self.qrreader_res)
+
+ # Update the video overlay with the results
+ self.video_overlay.set_results(self.qrreader_res, flip_x, self.validator_res)
+
+ # Close the dialog if the validator accepted the result
+ if self.validator_res.accepted:
+ self.accept()
+ return
+
+ # Apply the crop blur effect
+ if self.image_effect:
+ frame = self.image_effect.apply(frame)
+
+ # If horizontal flipping is enabled, only flip the display
+ if flip_x:
+ frame = frame.mirrored(True, False)
+
+ # Display the frame in the widget
+ self.video_widget.setPixmap(QPixmap.fromImage(frame))
+
+ self._update_stats(qr_scanned)
+
+ def _init_stats(self):
+ self.last_stats_time = time.perf_counter()
+ self.frame_counter = 0
+ self.qr_frame_counter = 0
+
+ def _update_stats(self, qr_scanned):
+ self.frame_counter += 1
+ if qr_scanned:
+ self.qr_frame_counter += 1
+ now = time.perf_counter()
+ last_stats_delta = now - self.last_stats_time
+ if last_stats_delta > 1.0: # stats every 1.0 seconds
+ fps = self.frame_counter / last_stats_delta
+ qr_fps = self.qr_frame_counter / last_stats_delta
+ #if self.validator is not None:
+ # self.validator.strong_count = math.ceil(qr_fps / 3) # 1/3 of a second's worth of qr frames determines strong_count
+ stats_format = 'running at {} FPS, scanner at {} FPS'
+ self.logger.info(stats_format.format(fps, qr_fps))
+ self.frame_counter = 0
+ self.qr_frame_counter = 0
+ self.last_stats_time = now
diff --git a/electrum/gui/qt/qrreader/qtmultimedia/crop_blur_effect.py b/electrum/gui/qt/qrreader/qtmultimedia/crop_blur_effect.py
new file mode 100644
index 000000000000..d1d5f2d6bf85
--- /dev/null
+++ b/electrum/gui/qt/qrreader/qtmultimedia/crop_blur_effect.py
@@ -0,0 +1,77 @@
+#!/usr/bin/env python3
+#
+# Electron Cash - lightweight Bitcoin client
+# Copyright (C) 2019 Axel Gembe
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from PyQt6.QtWidgets import QGraphicsBlurEffect, QGraphicsEffect
+from PyQt6.QtGui import QPainter, QTransform, QRegion
+from PyQt6.QtCore import QObject, QRect, QPoint, Qt
+
+
+class QrReaderCropBlurEffect(QGraphicsBlurEffect):
+ CROP_OFFSET_ENABLED = False
+ CROP_OFFSET = QPoint(5, 5)
+
+ BLUR_DARKEN = 0.25
+ BLUR_RADIUS = 8
+
+ def __init__(self, parent: QObject, crop: QRect = None):
+ super().__init__(parent)
+ self.crop = crop
+ self.setBlurRadius(self.BLUR_RADIUS)
+
+ def setCrop(self, crop: QRect = None):
+ self.crop = crop
+
+ def draw(self, painter: QPainter):
+ assert self.crop, 'crop must be set'
+
+ # Compute painter regions for the crop and the blur
+ all_region = QRegion(painter.viewport())
+ crop_region = QRegion(self.crop)
+ blur_region = all_region.subtracted(crop_region)
+
+ # Let the QGraphicsBlurEffect only paint in blur_region
+ painter.setClipRegion(blur_region)
+
+ # Fill with black and set opacity so that the blurred region is drawn darker
+ if self.BLUR_DARKEN > 0.0:
+ painter.fillRect(painter.viewport(), Qt.GlobalColor.black)
+ painter.setOpacity(1 - self.BLUR_DARKEN)
+
+ # Draw the blur effect
+ super().draw(painter)
+
+ # Restore clipping and opacity
+ painter.setClipping(False)
+ painter.setOpacity(1.0)
+
+ # Get the source pixmap
+ pixmap, offset = self.sourcePixmap(Qt.CoordinateSystem.DeviceCoordinates, QGraphicsEffect.PixmapPadMode.NoPad)
+ painter.setWorldTransform(QTransform())
+
+ # Get the source by adding the offset to the crop location
+ source = self.crop
+ if self.CROP_OFFSET_ENABLED:
+ source = source.translated(self.CROP_OFFSET)
+ painter.drawPixmap(self.crop.topLeft() + offset, pixmap, source)
diff --git a/electrum/gui/qt/qrreader/qtmultimedia/validator.py b/electrum/gui/qt/qrreader/qtmultimedia/validator.py
new file mode 100644
index 000000000000..39b67cf85781
--- /dev/null
+++ b/electrum/gui/qt/qrreader/qtmultimedia/validator.py
@@ -0,0 +1,168 @@
+#!/usr/bin/env python3
+#
+# Electron Cash - lightweight Bitcoin client
+# Copyright (C) 2019 Axel Gembe
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from typing import List, Dict, Callable, Any
+from abc import ABC, abstractmethod
+
+from PyQt6.QtGui import QColor
+from PyQt6.QtCore import Qt
+
+from electrum.i18n import _
+from electrum.qrreader import QrCodeResult
+
+from electrum.gui.qt.util import ColorScheme, QColorLerp
+
+
+class QrReaderValidatorResult():
+ """
+ Result of a QR code validator
+ """
+
+ def __init__(self):
+ self.accepted: bool = False
+
+ self.message: str = None
+ self.message_color: QColor = None
+
+ self.simple_result : str = None
+
+ self.result_usable: Dict[QrCodeResult, bool] = {}
+ self.result_colors: Dict[QrCodeResult, QColor] = {}
+ self.result_messages: Dict[QrCodeResult, str] = {}
+
+ self.selected_results: List[QrCodeResult] = []
+
+
+class AbstractQrReaderValidator(ABC):
+ """
+ Abstract base class for QR code result validators.
+ """
+
+ @abstractmethod
+ def validate_results(self, results: List[QrCodeResult]) -> QrReaderValidatorResult:
+ """
+ Checks a list of QR code results for usable codes.
+ """
+
+class QrReaderValidatorCounting(AbstractQrReaderValidator):
+ """
+ This QR code result validator doesn't directly accept any results but maintains a dictionary
+ of detection counts in `result_counts`.
+ """
+
+ result_counts: Dict[QrCodeResult, int] = {}
+
+ def validate_results(self, results: List[QrCodeResult]) -> QrReaderValidatorResult:
+ res = QrReaderValidatorResult()
+
+ for result in results:
+ # Increment the detection count
+ if result not in self.result_counts:
+ self.result_counts[result] = 0
+ self.result_counts[result] += 1
+
+ # Search for missing results, iterate over a copy because the loop might modify the dict
+ for result in self.result_counts.copy():
+ # Count down missing results
+ if result in results:
+ continue
+ self.result_counts[result] -= 2
+ # When the count goes to zero, remove
+ if self.result_counts[result] < 1:
+ del self.result_counts[result]
+
+ return res
+
+class QrReaderValidatorColorizing(QrReaderValidatorCounting):
+ """
+ This QR code result validator doesn't directly accept any results but colorizes the results
+ based on the counts maintained by `QrReaderValidatorCounting`.
+ """
+
+ WEAK_COLOR: QColor = QColor(Qt.GlobalColor.red)
+ STRONG_COLOR: QColor = QColor(Qt.GlobalColor.green)
+
+ strong_count: int = 2 # FIXME: make this time based rather than framect based
+ # note: we set a low strong_count to ~disable this mechanism and make QR codes
+ # much easier to scan (but potentially with some false positives)
+
+ def validate_results(self, results: List[QrCodeResult]) -> QrReaderValidatorResult:
+ res = super().validate_results(results)
+
+ # Colorize the QR code results by their detection counts
+ for result in results:
+ # Enforce strong_count as upper limit
+ self.result_counts[result] = min(self.result_counts[result], self.strong_count)
+
+ # Interpolate between WEAK_COLOR and STRONG_COLOR based on count / strong_count
+ lerp_factor = (self.result_counts[result] - 1) / self.strong_count
+ lerped_color = QColorLerp(self.WEAK_COLOR, self.STRONG_COLOR, lerp_factor)
+ res.result_colors[result] = lerped_color
+
+ return res
+
+class QrReaderValidatorStrong(QrReaderValidatorColorizing):
+ """
+ This QR code result validator doesn't directly accept any results but passes every strong
+ detection in the return values `selected_results`.
+ """
+
+ def validate_results(self, results: List[QrCodeResult]) -> QrReaderValidatorResult:
+ res = super().validate_results(results)
+
+ for result in results:
+ if self.result_counts[result] >= self.strong_count:
+ res.selected_results.append(result)
+ break
+
+ return res
+
+class QrReaderValidatorCounted(QrReaderValidatorStrong):
+ """
+ This QR code result validator accepts a result as soon as there is at least `minimum` and at
+ most `maximum` QR code(s) with strong detection.
+ """
+
+ def __init__(self, minimum: int = 1, maximum: int = 1):
+ super().__init__()
+ self.minimum = minimum
+ self.maximum = maximum
+
+ def validate_results(self, results: List[QrCodeResult]) -> QrReaderValidatorResult:
+ res = super().validate_results(results)
+
+ num_results = len(res.selected_results)
+ if num_results < self.minimum:
+ if num_results > 0:
+ res.message = _('Too few QR codes detected.')
+ res.message_color = ColorScheme.RED.as_color()
+ elif num_results > self.maximum:
+ res.message = _('Too many QR codes detected.')
+ res.message_color = ColorScheme.RED.as_color()
+ else:
+ res.accepted = True
+ res.simple_result = (results and results[0].data) or '' # hack added by calin just to take the first one
+
+ return res
diff --git a/electrum/gui/qt/qrreader/qtmultimedia/video_overlay.py b/electrum/gui/qt/qrreader/qtmultimedia/video_overlay.py
new file mode 100644
index 000000000000..16a7c4a8b20d
--- /dev/null
+++ b/electrum/gui/qt/qrreader/qtmultimedia/video_overlay.py
@@ -0,0 +1,157 @@
+#!/usr/bin/env python3
+#
+# Electron Cash - lightweight Bitcoin client
+# Copyright (C) 2019 Axel Gembe
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from typing import List
+
+from PyQt6.QtWidgets import QWidget
+from PyQt6.QtGui import QPainter, QPaintEvent, QPen, QPainterPath, QColor, QTransform
+from PyQt6.QtCore import QPoint, QSize, QRect, QRectF, Qt
+
+from electrum.qrreader import QrCodeResult
+
+from .validator import QrReaderValidatorResult
+
+
+class QrReaderVideoOverlay(QWidget):
+ """
+ Overlays the QR scanner results over the video
+ """
+
+ BG_RECT_PADDING = 10
+ BG_RECT_CORNER_RADIUS = 10.0
+ BG_RECT_OPACITY = 0.75
+
+ def __init__(self, parent: QWidget = None):
+ super().__init__(parent)
+
+ self.results = []
+ self.flip_x = False
+ self.validator_results = None
+ self.crop = None
+ self.resolution = None
+
+ self.qr_outline_pen = QPen()
+ self.qr_outline_pen.setColor(Qt.GlobalColor.red)
+ self.qr_outline_pen.setWidth(3)
+ self.qr_outline_pen.setStyle(Qt.PenStyle.DotLine)
+
+ self.text_pen = QPen()
+ self.text_pen.setColor(Qt.GlobalColor.black)
+
+ self.bg_rect_pen = QPen()
+ self.bg_rect_pen.setColor(Qt.GlobalColor.black)
+ self.bg_rect_pen.setStyle(Qt.PenStyle.DotLine)
+ self.bg_rect_fill = QColor(255, 255, 255, int(255 * self.BG_RECT_OPACITY))
+
+ def set_results(self, results: List[QrCodeResult], flip_x: bool,
+ validator_results: QrReaderValidatorResult):
+ self.results = results
+ self.flip_x = flip_x
+ self.validator_results = validator_results
+ self.update()
+
+ def set_crop(self, crop: QRect):
+ self.crop = crop
+
+ def set_resolution(self, resolution: QSize):
+ self.resolution = resolution
+
+ def paintEvent(self, _event: QPaintEvent):
+ if not self.crop or not self.resolution:
+ return
+
+ painter = QPainter(self)
+
+ # Keep a backup of the transform and create a new one
+ transform = painter.worldTransform()
+
+ # Set scaling transform
+ transform = transform.scale(self.width() / self.resolution.width(),
+ self.height() / self.resolution.height())
+
+ # Compute the transform to flip the coordinate system on the x axis
+ transform_flip = QTransform()
+ if self.flip_x:
+ transform_flip = transform_flip.translate(self.resolution.width(), 0.0)
+ transform_flip = transform_flip.scale(-1.0, 1.0)
+
+ # Small helper for tuple to QPoint
+ def toqp(point):
+ return QPoint(point[0], point[1])
+
+ # Starting from here we care about AA
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+
+ # Draw all the QR code results
+ for res in self.results:
+ painter.setWorldTransform(transform_flip * transform, False)
+
+ # Draw lines between all of the QR code points
+ pen = QPen(self.qr_outline_pen)
+ if res in self.validator_results.result_colors:
+ pen.setColor(self.validator_results.result_colors[res])
+ painter.setPen(pen)
+ num_points = len(res.points)
+ for i in range(0, num_points):
+ i_n = i + 1
+
+ line_from = toqp(res.points[i])
+ line_from += self.crop.topLeft()
+
+ line_to = toqp(res.points[i_n] if i_n < num_points else res.points[0])
+ line_to += self.crop.topLeft()
+
+ painter.drawLine(line_from, line_to)
+
+ # Draw the QR code data
+ # Note that we reset the world transform to only the scaled transform
+ # because otherwise the text could be flipped. We only use transform_flip
+ # to map the center point of the result.
+ painter.setWorldTransform(transform, False)
+ font_metrics = painter.fontMetrics()
+ data_metrics = QSize(font_metrics.horizontalAdvance(res.data), font_metrics.capHeight())
+
+ center_pos = toqp(res.center)
+ center_pos += self.crop.topLeft()
+ center_pos = transform_flip.map(center_pos)
+
+ text_offset = QPoint(data_metrics.width(), data_metrics.height())
+ text_offset = text_offset / 2
+ text_offset.setX(-text_offset.x())
+ center_pos += text_offset
+
+ padding = self.BG_RECT_PADDING
+ bg_rect_pos = center_pos - QPoint(padding, data_metrics.height() + padding)
+ bg_rect_size = data_metrics + (QSize(padding, padding) * 2)
+ bg_rect = QRect(bg_rect_pos, bg_rect_size)
+ bg_rect_path = QPainterPath()
+ radius = self.BG_RECT_CORNER_RADIUS
+ bg_rect_path.addRoundedRect(QRectF(bg_rect), radius, radius, Qt.SizeMode.AbsoluteSize)
+ painter.setPen(self.bg_rect_pen)
+ painter.fillPath(bg_rect_path, self.bg_rect_fill)
+ painter.drawPath(bg_rect_path)
+
+ painter.setPen(self.text_pen)
+ painter.drawText(center_pos, res.data)
diff --git a/electrum/gui/qt/qrreader/qtmultimedia/video_surface.py b/electrum/gui/qt/qrreader/qtmultimedia/video_surface.py
new file mode 100644
index 000000000000..6573673e67eb
--- /dev/null
+++ b/electrum/gui/qt/qrreader/qtmultimedia/video_surface.py
@@ -0,0 +1,81 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2019 Axel Gembe
+# Copyright (c) 2024 The Electrum developers
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from typing import List
+
+from PyQt6.QtMultimedia import (QVideoFrame, QVideoFrameFormat, QVideoSink)
+from PyQt6.QtGui import QImage
+from PyQt6.QtCore import QObject, pyqtSignal
+
+from electrum.i18n import _
+from electrum.logging import get_logger
+
+
+_logger = get_logger(__name__)
+
+
+class QrReaderVideoSurface(QVideoSink):
+ """
+ Receives QVideoFrames from QCamera, converts them into a QImage, flips the X and Y axis if
+ necessary and sends them to listeners via the frame_available event.
+ """
+
+ def __init__(self, parent: QObject = None):
+ super().__init__(parent)
+ self.videoFrameChanged.connect(self._on_new_frame)
+
+ def _on_new_frame(self, frame: QVideoFrame) -> None:
+ if not frame.isValid():
+ return
+
+ image_format = QVideoFrameFormat.imageFormatFromPixelFormat(frame.pixelFormat())
+ if image_format == QVideoFrameFormat.PixelFormat.Format_Invalid:
+ _logger.info(_('QR code scanner for video frame with invalid pixel format'))
+ return
+
+ if not frame.map(QVideoFrame.MapMode.ReadOnly):
+ _logger.info(_('QR code scanner failed to map video frame'))
+ return
+
+ try:
+ img = frame.toImage()
+
+ # Check whether we need to flip the image on any axis
+ surface_format = frame.surfaceFormat()
+ flip_x = surface_format.isMirrored()
+ flip_y = surface_format.scanLineDirection() == QVideoFrameFormat.Direction.BottomToTop
+
+ # Mirror the image if needed
+ if flip_x or flip_y:
+ img = img.mirrored(flip_x, flip_y)
+
+ # Create a copy of the image so the original frame data can be freed
+ img = img.copy()
+ finally:
+ frame.unmap()
+
+ self.frame_available.emit(img)
+
+ frame_available = pyqtSignal(QImage)
diff --git a/electrum/gui/qt/qrreader/qtmultimedia/video_widget.py b/electrum/gui/qt/qrreader/qtmultimedia/video_widget.py
new file mode 100644
index 000000000000..123a37da2049
--- /dev/null
+++ b/electrum/gui/qt/qrreader/qtmultimedia/video_widget.py
@@ -0,0 +1,52 @@
+#!/usr/bin/env python3
+#
+# Electron Cash - lightweight Bitcoin client
+# Copyright (C) 2019 Axel Gembe
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from PyQt6.QtWidgets import QWidget
+from PyQt6.QtGui import QPixmap, QPainter, QPaintEvent
+
+
+class QrReaderVideoWidget(QWidget):
+ """
+ Simple widget for drawing a pixmap
+ """
+
+ USE_BILINEAR_FILTER = True
+
+ def __init__(self, parent: QWidget = None):
+ super().__init__(parent)
+
+ self.pixmap = None
+
+ def paintEvent(self, _event: QPaintEvent):
+ if not self.pixmap:
+ return
+ painter = QPainter(self)
+ if self.USE_BILINEAR_FILTER:
+ painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
+ painter.drawPixmap(self.rect(), self.pixmap, self.pixmap.rect())
+
+ def setPixmap(self, pixmap: QPixmap):
+ self.pixmap = pixmap
+ self.update()
diff --git a/electrum/gui/qt/qrtextedit.py b/electrum/gui/qt/qrtextedit.py
new file mode 100644
index 000000000000..74205e0c815a
--- /dev/null
+++ b/electrum/gui/qt/qrtextedit.py
@@ -0,0 +1,97 @@
+from functools import partial
+from typing import Callable
+
+from electrum.i18n import _
+from electrum.plugin import run_hook
+from electrum.simple_config import SimpleConfig
+
+from .util import ButtonsTextEdit, MessageBoxMixin, ColorScheme, read_QIcon
+from .util import get_icon_camera, get_icon_qrcode, add_input_actions_to_context_menu
+
+
+class ShowQRTextEdit(ButtonsTextEdit):
+
+ def __init__(self, text=None, *, config: SimpleConfig):
+ ButtonsTextEdit.__init__(self, text)
+ self.setReadOnly(True)
+ self.add_qr_show_button(config=config)
+ run_hook('show_text_edit', self)
+
+ def contextMenuEvent(self, e):
+ m = self.createStandardContextMenu()
+ m.addAction(get_icon_qrcode(), _("Show as QR code"), self.on_qr_show_btn)
+ m.exec(e.globalPos())
+
+
+class ScanQRTextEdit(ButtonsTextEdit, MessageBoxMixin):
+
+ def __init__(
+ self, text="", allow_multi: bool = False,
+ *,
+ config: SimpleConfig,
+ setText: Callable[[str], None] = None,
+ is_payto = False,
+ ):
+ ButtonsTextEdit.__init__(self, text)
+ self.setReadOnly(False)
+ self.on_qr_from_camera_input_btn = partial(
+ self.input_qr_from_camera,
+ config=config,
+ allow_multi=allow_multi,
+ show_error=self.show_error,
+ setText=setText,
+ )
+ self.on_qr_from_screenshot_input_btn = partial(
+ self.input_qr_from_screenshot,
+ allow_multi=allow_multi,
+ show_error=self.show_error,
+ setText=setText,
+ )
+ self.on_qr_from_file_input_btn = partial(
+ self.input_qr_from_file,
+ allow_multi=allow_multi,
+ config=config,
+ show_error=self.show_error,
+ setText=setText,
+ )
+ self.on_input_file = partial(
+ self.input_file,
+ config=config,
+ show_error=self.show_error,
+ setText=setText,
+ )
+ # for send tab, buttons are available in the toolbar
+ if not is_payto:
+ self.add_input_buttons(config, allow_multi, setText)
+ run_hook('scan_text_edit', self)
+
+ def add_input_buttons(self, config, allow_multi, setText):
+ self.add_menu_button(
+ options=[
+ ("picture_in_picture.png", _("Read QR code from screen"), self.on_qr_from_screenshot_input_btn),
+ ("qr_file.png", _("Read QR code from file"), self.on_qr_from_file_input_btn),
+ ("file.png", _("Read text from file"), self.on_input_file),
+ ],
+ )
+ self.add_qr_input_from_camera_button(config=config, show_error=self.show_error, allow_multi=allow_multi, setText=setText)
+
+ def contextMenuEvent(self, e):
+ m = self.createStandardContextMenu()
+ m.addSeparator()
+ add_input_actions_to_context_menu(self, m)
+ m.exec(e.globalPos())
+
+
+class ScanShowQRTextEdit(ScanQRTextEdit):
+
+ def __init__(self, *args, config: SimpleConfig, **kwargs):
+ ScanQRTextEdit.__init__(self, *args, **kwargs, config=config)
+ self.add_qr_show_button(config=config)
+ run_hook('show_text_edit', self)
+
+ def contextMenuEvent(self, e):
+ m = self.createStandardContextMenu()
+ m.addSeparator()
+ add_input_actions_to_context_menu(self, m)
+ m.addAction(get_icon_qrcode(), _("Show as QR code"), self.on_qr_show_btn)
+ m.exec(e.globalPos())
diff --git a/electrum/gui/qt/qrwindow.py b/electrum/gui/qt/qrwindow.py
new file mode 100644
index 000000000000..497368b5958d
--- /dev/null
+++ b/electrum/gui/qt/qrwindow.py
@@ -0,0 +1,48 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2014 Thomas Voegtlin
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from PyQt6.QtCore import Qt
+from PyQt6.QtWidgets import QHBoxLayout, QWidget
+
+from .qrcodewidget import QRCodeWidget
+
+from electrum.i18n import _
+
+
+class QR_Window(QWidget):
+
+ def __init__(self, win):
+ QWidget.__init__(self)
+ self.main_window = win
+ self.setWindowTitle('Electrum - '+_('Payment Request'))
+ self.setMinimumSize(800, 800)
+ self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
+ main_box = QHBoxLayout()
+ self.qrw = QRCodeWidget()
+ main_box.addWidget(self.qrw, 1)
+ self.setLayout(main_box)
+
+ def closeEvent(self, event):
+ self.main_window.receive_tab.qr_menu_action.setChecked(False)
diff --git a/electrum/gui/qt/rate_limiter.py b/electrum/gui/qt/rate_limiter.py
new file mode 100644
index 000000000000..f108feaf255c
--- /dev/null
+++ b/electrum/gui/qt/rate_limiter.py
@@ -0,0 +1,234 @@
+# Copyright (c) 2019 Calin Culianu
+# Distributed under the MIT software license, see the accompanying
+# file LICENCE or http://www.opensource.org/licenses/mit-license.php
+
+from functools import wraps
+import threading
+import time
+import weakref
+
+from PyQt6.QtCore import QObject, QTimer
+
+from electrum.logging import Logger, get_logger
+
+
+_logger = get_logger(__name__)
+
+
+class RateLimiter(Logger):
+ ''' Manages the state of a @rate_limited decorated function, collating
+ multiple invocations. This class is not intended to be used directly. Instead,
+ use the @rate_limited decorator (for instance methods).
+ This state instance gets inserted into the instance attributes of the target
+ object wherever a @rate_limited decorator appears.
+ The inserted attribute is named "__FUNCNAME__RateLimiter". '''
+ # some defaults
+ last_ts = 0.0
+ timer = None
+ saved_args = (tuple(),dict())
+ ctr = 0
+
+ def __init__(self, rate, ts_after, obj, func):
+ self.n = func.__name__
+ self.qn = func.__qualname__
+ self.rate = rate
+ self.ts_after = ts_after
+ self.obj = weakref.ref(obj) # keep a weak reference to the object to prevent cycles
+ self.func = func
+ Logger.__init__(self)
+ #self.logger.debug(f"*** Created: {func=},{obj=},{rate=}")
+
+ def diagnostic_name(self):
+ return "{}:{}".format("rate_limited",self.qn)
+
+ def kill_timer(self):
+ if self.timer:
+ #self.logger.debug("deleting timer")
+ try:
+ self.timer.stop()
+ self.timer.deleteLater()
+ except RuntimeError as e:
+ if 'c++ object' in str(e).lower():
+ # This can happen if the attached object which actually owns
+ # QTimer is deleted by Qt before this call path executes.
+ # This call path may be executed from a queued connection in
+ # some circumstances, hence the crazyness (I think).
+ self.logger.debug("advisory: QTimer was already deleted by Qt, ignoring...")
+ else:
+ raise
+ finally:
+ self.timer = None
+
+ @classmethod
+ def attr_name(cls, func): return "__{}__{}".format(func.__name__, cls.__name__)
+
+ @classmethod
+ def invoke(cls, rate, ts_after, func, args, kwargs):
+ ''' Calls _invoke() on an existing RateLimiter object (or creates a new
+ one for the given function on first run per target object instance). '''
+ assert args and isinstance(args[0], object), "@rate_limited decorator may only be used with object instance methods"
+ assert threading.current_thread() is threading.main_thread(), "@rate_limited decorator may only be used with functions called in the main thread"
+ obj = args[0]
+ a_name = cls.attr_name(func)
+ #_logger.debug(f"*** {a_name=}, {obj=}")
+ rl = getattr(obj, a_name, None) # we hide the RateLimiter state object in an attribute (name based on the wrapped function name) in the target object
+ if rl is None:
+ # must be the first invocation, create a new RateLimiter state instance.
+ rl = cls(rate, ts_after, obj, func)
+ setattr(obj, a_name, rl)
+ return rl._invoke(args, kwargs)
+
+ def _invoke(self, args, kwargs):
+ self._push_args(args, kwargs) # since we're collating, save latest invocation's args unconditionally. any future invocation will use the latest saved args.
+ self.ctr += 1 # increment call counter
+ #self.logger.debug(f"args_saved={args}, kwarg_saved={kwargs}")
+ if not self.timer: # check if there's a pending invocation already
+ now = time.time()
+ diff = float(self.rate) - (now - self.last_ts)
+ if diff <= 0:
+ # Time since last invocation was greater than self.rate, so call the function directly now.
+ #self.logger.debug("calling directly")
+ return self._doIt()
+ else:
+ # Time since last invocation was less than self.rate, so defer to the future with a timer.
+ self.timer = QTimer(self.obj() if isinstance(self.obj(), QObject) else None)
+ self.timer.timeout.connect(self._doIt)
+ #self.timer.destroyed.connect(lambda x=None,qn=self.qn: print(qn,"Timer deallocated"))
+ self.timer.setSingleShot(True)
+ self.timer.start(int(diff*1e3))
+ #self.logger.debug("deferring")
+ else:
+ # We had a timer active, which means as future call will occur. So return early and let that call happen in the future.
+ # Note that a side-effect of this aborted invocation was to update self.saved_args.
+ pass
+ #self.logger.debug("ignoring (already scheduled)")
+
+ def _pop_args(self):
+ args, kwargs = self.saved_args # grab the latest collated invocation's args. this attribute is always defined.
+ self.saved_args = (tuple(),dict()) # clear saved args immediately
+ return args, kwargs
+
+ def _push_args(self, args, kwargs):
+ self.saved_args = (args, kwargs)
+
+ def _doIt(self):
+ #self.logger.debug("called!")
+ t0 = time.time()
+ args, kwargs = self._pop_args()
+ #self.logger.debug(f"args_actually_used={args}, kwarg_actually_used={kwargs}")
+ ctr0 = self.ctr # read back current call counter to compare later for reentrancy detection
+ retval = self.func(*args, **kwargs) # and.. call the function. use latest invocation's args
+ was_reentrant = self.ctr != ctr0 # if ctr is not the same, func() led to a call this function!
+ del args, kwargs # deref args right away (allow them to get gc'd)
+ tf = time.time()
+ time_taken = tf-t0
+ if self.ts_after:
+ self.last_ts = tf
+ else:
+ if time_taken > float(self.rate):
+ self.logger.debug(f"method took too long: {time_taken} > {self.rate}. Fudging timestamps to compensate.")
+ self.last_ts = tf # Hmm. This function takes longer than its rate to complete. so mark its last run time as 'now'. This breaks the rate but at least prevents this function from starving the CPU (benforces a delay).
+ else:
+ self.last_ts = t0 # Function takes less than rate to complete, so mark its t0 as when we entered to keep the rate constant.
+
+ if self.timer: # timer is not None if and only if we were a delayed (collated) invocation.
+ if was_reentrant:
+ # we got a reentrant call to this function as a result of calling func() above! re-schedule the timer.
+ self.logger.debug("*** detected a re-entrant call, re-starting timer")
+ time_left = float(self.rate) - (tf - self.last_ts)
+ self.timer.start(time_left*1e3)
+ else:
+ # We did not get a reentrant call, so kill the timer so subsequent calls can schedule the timer and/or call func() immediately.
+ self.kill_timer()
+ elif was_reentrant:
+ self.logger.debug("*** detected a re-entrant call")
+
+ return retval
+
+
+class RateLimiterClassLvl(RateLimiter):
+ ''' This RateLimiter object is used if classlevel=True is specified to the
+ @rate_limited decorator. It inserts the __RateLimiterClassLvl state object
+ on the class level and collates calls for all instances to not exceed rate.
+ Each instance is guaranteed to receive at least 1 call and to have multiple
+ calls updated with the latest args for the final call. So for instance:
+ a.foo(1)
+ a.foo(2)
+ b.foo(10)
+ b.foo(3)
+ Would collate to a single 'class-level' call using 'rate':
+ a.foo(2) # latest arg taken, collapsed to 1 call
+ b.foo(3) # latest arg taken, collapsed to 1 call
+ '''
+
+ @classmethod
+ def invoke(cls, rate, ts_after, func, args, kwargs):
+ assert args and not isinstance(args[0], type), "@rate_limited decorator may not be used with static or class methods"
+ obj = args[0]
+ objcls = obj.__class__
+ args = list(args)
+ args.insert(0, objcls) # prepend obj class to trick super.invoke() into making this state object be class-level.
+ return super(RateLimiterClassLvl, cls).invoke(rate, ts_after, func, args, kwargs)
+
+ def _push_args(self, args, kwargs):
+ objcls, obj = args[0:2]
+ args = args[2:]
+ self.saved_args[obj] = (args, kwargs)
+
+ def _pop_args(self):
+ weak_dict = self.saved_args
+ self.saved_args = weakref.WeakKeyDictionary()
+ return (weak_dict,),dict()
+
+ def _call_func_for_all(self, weak_dict):
+ for ref in weak_dict.keyrefs():
+ obj = ref()
+ if obj:
+ args,kwargs = weak_dict[obj]
+ obj_name = obj.diagnostic_name() if hasattr(obj, "diagnostic_name") else obj
+ #self.logger.debug(f"calling for {obj_name}, timer={bool(self.timer)}")
+ self.func_target(obj, *args, **kwargs)
+
+ def __init__(self, rate, ts_after, obj, func):
+ # note: obj here is really the __class__ of the obj because we prepended the class in our custom invoke() above.
+ super().__init__(rate, ts_after, obj, func)
+ self.func_target = func
+ self.func = self._call_func_for_all
+ self.saved_args = weakref.WeakKeyDictionary() # we don't use a simple arg tuple, but instead an instance -> args,kwargs dictionary to store collated calls, per instance collated
+
+
+def rate_limited(rate, *, classlevel=False, ts_after=False):
+ """ A Function decorator for rate-limiting GUI event callbacks. Argument
+ rate in seconds is the minimum allowed time between subsequent calls of
+ this instance of the function. Calls that arrive more frequently than
+ rate seconds will be collated into a single call that is deferred onto
+ a QTimer. It is preferable to use this decorator on QObject subclass
+ instance methods. This decorator is particularly useful in limiting
+ frequent calls to GUI update functions.
+ params:
+ rate - calls are collated to not exceed rate (in seconds)
+ classlevel - if True, specify that the calls should be collated at
+ 1 per `rate` secs. for *all* instances of a class, otherwise
+ calls will be collated on a per-instance basis.
+ ts_after - if True, mark the timestamp of the 'last call' AFTER the
+ target method completes. That is, the collation of calls will
+ ensure at least `rate` seconds will always elapse between
+ subsequent calls. If False, the timestamp is taken right before
+ the collated calls execute (thus ensuring a fixed period for
+ collated calls).
+ TL;DR: ts_after=True : `rate` defines the time interval you want
+ from last call's exit to entry into next
+ call.
+ ts_adter=False: `rate` defines the time between each
+ call's entry.
+ (See on_fx_quotes & on_fx_history in main_window.py for example usages
+ of this decorator). """
+ def wrapper0(func):
+ @wraps(func)
+ def wrapper(*args, **kwargs):
+ if classlevel:
+ return RateLimiterClassLvl.invoke(rate, ts_after, func, args, kwargs)
+ return RateLimiter.invoke(rate, ts_after, func, args, kwargs)
+ return wrapper
+ return wrapper0
+
diff --git a/electrum/gui/qt/rbf_dialog.py b/electrum/gui/qt/rbf_dialog.py
new file mode 100644
index 000000000000..7eb96e16ed85
--- /dev/null
+++ b/electrum/gui/qt/rbf_dialog.py
@@ -0,0 +1,182 @@
+# Copyright (C) 2021 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 PyQt6.QtCore import Qt
+from PyQt6.QtWidgets import QLabel, QGridLayout, QHBoxLayout, QComboBox
+
+from .util import ColorScheme
+
+from electrum.i18n import _
+from electrum.transaction import PartialTransaction
+from electrum.wallet import CannotRBFTx, BumpFeeStrategy
+
+if TYPE_CHECKING:
+ from .main_window import ElectrumWindow
+
+
+from .confirm_tx_dialog import TxEditor, TxSizeLabel, HelpLabel
+
+
+class _BaseRBFDialog(TxEditor):
+
+ def __init__(
+ self,
+ *,
+ main_window: 'ElectrumWindow',
+ tx: PartialTransaction,
+ title: str):
+
+ self.wallet = main_window.wallet
+ self.old_tx = tx
+ self.message = ''
+
+ self.old_fee = self.old_tx.get_fee()
+ self.old_tx_size = tx.estimated_size()
+ self.old_fee_rate = old_fee_rate = self.old_fee / self.old_tx_size # sat/vbyte
+
+ output_value = sum([txo.value for txo in tx.outputs() if not txo.is_mine])
+ if output_value == 0:
+ output_value = tx.output_value()
+
+ TxEditor.__init__(
+ self,
+ window=main_window,
+ title=title,
+ make_tx=self.rbf_func,
+ output_value=output_value,
+ )
+
+ self.fee_e.setFrozen(True) # disallow setting absolute fee for now, as wallet.bump_fee can only target feerate
+ new_fee_rate = self.old_fee_rate + max(1, self.old_fee_rate // 20)
+ self.feerate_e.setAmount(new_fee_rate)
+ self.update()
+ self.fee_slider.deactivate()
+
+ def create_grid(self):
+ self.method_label = QLabel(_('Method') + ':')
+ self.method_combo = QComboBox()
+ self._strategies, def_strat_idx = self.wallet.get_bumpfee_strategies_for_tx(tx=self.old_tx)
+ self.method_combo.addItems([strat.text() for strat in self._strategies])
+ self.method_combo.setCurrentIndex(def_strat_idx)
+ self.method_combo.currentIndexChanged.connect(self.trigger_update)
+ self.method_combo.setFocusPolicy(Qt.FocusPolicy.NoFocus)
+ old_size_label = TxSizeLabel()
+ old_size_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ old_size_label.setAmount(self.old_tx_size)
+ old_size_label.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet())
+ current_fee_hbox = QHBoxLayout()
+ current_fee_hbox.addWidget(QLabel(self.main_window.format_fee_rate(1000 * self.old_fee_rate)))
+ current_fee_hbox.addWidget(old_size_label)
+ current_fee_hbox.addWidget(QLabel(self.main_window.format_amount_and_units(self.old_fee)))
+ current_fee_hbox.addStretch()
+ grid = QGridLayout()
+ grid.addWidget(self.method_label, 0, 0)
+ grid.addWidget(self.method_combo, 0, 1)
+ grid.addWidget(QLabel(_('Current fee') + ':'), 1, 0)
+ grid.addLayout(current_fee_hbox, 1, 1, 1, 3)
+ grid.addWidget(QLabel(_('New fee') + ':'), 2, 0)
+ grid.addLayout(self.fee_hbox, 2, 1, 1, 3)
+ grid.addWidget(HelpLabel(_("Fee target") + ": ", self.fee_combo.help_msg), 4, 0)
+ grid.addLayout(self.fee_target_hbox, 4, 1, 1, 3)
+ grid.setColumnStretch(4, 1)
+ # locktime
+ grid.addWidget(self.locktime_label, 5, 0)
+ grid.addWidget(self.locktime_e, 5, 1, 1, 2)
+ return grid
+
+ def run(self) -> None:
+ if not self.exec():
+ return
+ if self.is_preview:
+ self.main_window.show_transaction(self.tx)
+ return
+
+ def sign_done(success):
+ if success:
+ self.main_window.broadcast_or_show(self.tx)
+
+ self.main_window.sign_tx(
+ self.tx,
+ callback=sign_done,
+ external_keypairs={})
+
+ def update_tx(self):
+ fee_rate = self.feerate_e.get_amount()
+ if fee_rate is None:
+ self.tx = None
+ self.error = _('No fee rate')
+ elif fee_rate <= self.old_fee_rate:
+ self.tx = None
+ self.error = _("The new fee rate needs to be higher than the old fee rate.")
+ else:
+ try:
+ self.tx = self.make_tx(fee_rate)
+ except CannotRBFTx as e:
+ self.tx = None
+ self.error = str(e)
+
+ def get_messages(self):
+ messages = super().get_messages()
+ if not self.tx:
+ return
+ delta = self.tx.get_fee() - self.old_tx.get_fee()
+ if self._strategies[self.method_combo.currentIndex()] == BumpFeeStrategy.PRESERVE_PAYMENT:
+ msg = _("You will pay {} more.").format(self.main_window.format_amount_and_units(delta))
+ elif self._strategies[self.method_combo.currentIndex()] == BumpFeeStrategy.DECREASE_PAYMENT:
+ msg = _("The recipient will receive {} less.").format(self.main_window.format_amount_and_units(delta))
+ else:
+ raise Exception(f"unknown strategy: {self=}")
+ messages.insert(0, msg)
+ return messages
+
+
+class BumpFeeDialog(_BaseRBFDialog):
+
+ help_text = _("Increase your transaction's fee to improve its position in mempool.")
+
+ def __init__(
+ self,
+ *,
+ main_window: 'ElectrumWindow',
+ tx: PartialTransaction,
+ ):
+ _BaseRBFDialog.__init__(
+ self,
+ main_window=main_window,
+ tx=tx,
+ title=_('Bump Fee'))
+
+ def rbf_func(self, fee_rate, *, confirmed_only=False):
+ return self.wallet.bump_fee(
+ tx=self.old_tx,
+ new_fee_rate=fee_rate,
+ coins=self.main_window.get_coins(nonlocal_only=True, confirmed_only=confirmed_only),
+ strategy=self._strategies[self.method_combo.currentIndex()],
+ )
+
+
+class DSCancelDialog(_BaseRBFDialog):
+
+ help_text = _(
+ "Cancel an unconfirmed transaction by replacing it with "
+ "a higher-fee transaction that spends back to your wallet.")
+
+ def __init__(
+ self,
+ *,
+ main_window: 'ElectrumWindow',
+ tx: PartialTransaction,
+ ):
+ _BaseRBFDialog.__init__(
+ self,
+ main_window=main_window,
+ tx=tx,
+ title=_('Cancel transaction'))
+ self.method_label.setVisible(False)
+ self.method_combo.setVisible(False)
+
+ def rbf_func(self, fee_rate, *, confirmed_only=False):
+ return self.wallet.dscancel(tx=self.old_tx, new_fee_rate=fee_rate)
diff --git a/electrum/gui/qt/rebalance_dialog.py b/electrum/gui/qt/rebalance_dialog.py
new file mode 100644
index 000000000000..8906dcd8e210
--- /dev/null
+++ b/electrum/gui/qt/rebalance_dialog.py
@@ -0,0 +1,75 @@
+from typing import TYPE_CHECKING
+
+from PyQt6.QtWidgets import QLabel, QVBoxLayout, QGridLayout, QPushButton
+
+from electrum.i18n import _
+from electrum.lnchannel import Channel
+
+from .util import WindowModalDialog, Buttons, OkButton, CancelButton, WWLabel
+from .amountedit import BTCAmountEdit
+
+if TYPE_CHECKING:
+ from .main_window import ElectrumWindow
+
+
+class RebalanceDialog(WindowModalDialog):
+
+ def __init__(self, window: 'ElectrumWindow', chan1: Channel, chan2: Channel, amount_sat):
+ WindowModalDialog.__init__(self, window, _("Rebalance channels"))
+ self.window = window
+ self.wallet = window.wallet
+ self.chan1 = chan1
+ self.chan2 = chan2
+ vbox = QVBoxLayout(self)
+ vbox.addWidget(WWLabel(_('Rebalance your channels in order to increase your sending or receiving capacity') + ':'))
+ grid = QGridLayout()
+ self.amount_e = BTCAmountEdit(self.window.get_decimal_point)
+ self.amount_e.setAmount(amount_sat)
+ self.amount_e.textChanged.connect(self.on_amount)
+ self.rev_button = QPushButton(u'\U000021c4')
+ self.rev_button.clicked.connect(self.on_reverse)
+ self.max_button = QPushButton('Max')
+ self.max_button.clicked.connect(self.on_max)
+ self.label1 = QLabel('')
+ self.label2 = QLabel('')
+ self.ok_button = OkButton(self)
+ self.ok_button.setEnabled(False)
+ grid.addWidget(QLabel(_("From channel")), 0, 0)
+ grid.addWidget(self.label1, 0, 1)
+ grid.addWidget(QLabel(_("To channel")), 1, 0)
+ grid.addWidget(self.label2, 1, 1)
+ grid.addWidget(QLabel(_("Amount")), 2, 0)
+ grid.addWidget(self.amount_e, 2, 1)
+ grid.addWidget(self.max_button, 2, 2)
+ grid.addWidget(self.rev_button, 0, 2)
+ vbox.addLayout(grid)
+ vbox.addLayout(Buttons(CancelButton(self), self.ok_button))
+ self.update()
+
+ def on_reverse(self, x):
+ a, b = self.chan1, self.chan2
+ self.chan1, self.chan2 = b, a
+ self.amount_e.setAmount(None)
+ self.update()
+
+ def on_amount(self, x):
+ self.update()
+
+ def on_max(self, x):
+ n_sat = self.wallet.lnworker.num_sats_can_rebalance(self.chan1, self.chan2)
+ self.amount_e.setAmount(n_sat)
+
+ def update(self):
+ self.label1.setText(self.chan1.short_id_for_GUI())
+ self.label2.setText(self.chan2.short_id_for_GUI())
+ amount_sat = self.amount_e.get_amount()
+ b = bool(amount_sat) and self.wallet.lnworker.num_sats_can_rebalance(self.chan1, self.chan2) >= amount_sat
+ self.ok_button.setEnabled(b)
+
+ def run(self):
+ if not self.exec():
+ return
+ amount_msat = self.amount_e.get_amount() * 1000
+ coro = self.wallet.lnworker.rebalance_channels(self.chan1, self.chan2, amount_msat=amount_msat)
+ self.window.run_coroutine_from_thread(coro, _('Rebalancing channels'))
+ self.window.receive_tab.update_current_request() # this will gray out the button
diff --git a/electrum/gui/qt/receive_tab.py b/electrum/gui/qt/receive_tab.py
new file mode 100644
index 000000000000..fe60c56989bb
--- /dev/null
+++ b/electrum/gui/qt/receive_tab.py
@@ -0,0 +1,425 @@
+# Copyright (C) 2022 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 Optional, TYPE_CHECKING
+
+from PyQt6.QtGui import QFont, QCursor, QMouseEvent
+from PyQt6.QtCore import Qt, QSize
+from PyQt6.QtWidgets import (QLabel, QVBoxLayout, QGridLayout, QTextEdit,
+ QHBoxLayout, QPushButton, QWidget, QSizePolicy, QFrame)
+
+from electrum.i18n import _
+from electrum.util import InvoiceError, ChoiceItem
+from electrum.invoices import pr_expiration_values
+from electrum.logging import Logger
+
+from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit
+from .qrcodewidget import QRCodeWidget
+from .util import read_QIcon, WWLabel, MessageBoxMixin, MONOSPACE_FONT, get_icon_qrcode
+
+if TYPE_CHECKING:
+ from .main_window import ElectrumWindow
+ from electrum.wallet import Request
+
+
+class ReceiveTab(QWidget, MessageBoxMixin, Logger):
+
+ # strings updated by update_current_request
+ addr = ''
+ lnaddr = ''
+ URI = ''
+ address_help = ''
+ URI_help = ''
+ ln_help = ''
+
+ def __init__(self, window: 'ElectrumWindow'):
+ QWidget.__init__(self, window)
+ Logger.__init__(self)
+
+ self.window = window
+ self.wallet = window.wallet
+ self.fx = window.fx
+ self.config = window.config
+
+ # A 4-column grid layout. All the stretch is in the last column.
+ # The exchange rate plugin adds a fiat widget in column 2
+ self.receive_grid = grid = QGridLayout()
+ grid.setSpacing(8)
+ grid.setColumnStretch(3, 1)
+
+ self.receive_message_e = SizedFreezableLineEdit(width=400)
+ grid.addWidget(QLabel(_('Description')), 0, 0)
+ grid.addWidget(self.receive_message_e, 0, 1, 1, 4)
+
+ self.receive_amount_e = BTCAmountEdit(self.window.get_decimal_point)
+ grid.addWidget(QLabel(_('Requested amount')), 1, 0)
+ grid.addWidget(self.receive_amount_e, 1, 1)
+
+ self.fiat_receive_e = AmountEdit(self.fx.get_currency if self.fx else '')
+ if not self.fx or not self.fx.is_enabled():
+ self.fiat_receive_e.setVisible(False)
+ grid.addWidget(self.fiat_receive_e, 1, 2, Qt.AlignmentFlag.AlignLeft)
+
+ self.window.connect_fields(self.receive_amount_e, self.fiat_receive_e)
+
+ self.expiry_button = QPushButton('')
+ self.expiry_button.clicked.connect(self.expiry_dialog)
+ grid.addWidget(QLabel(_('Expiry')), 2, 0)
+ grid.addWidget(self.expiry_button, 2, 1)
+
+ self.clear_invoice_button = QPushButton(_('Clear'))
+ self.clear_invoice_button.clicked.connect(self.do_clear)
+ text = _('Onchain') if self.wallet.has_lightning() else _('Request')
+ self.create_onchain_invoice_button = QPushButton(text)
+ self.create_onchain_invoice_button.setIcon(read_QIcon("bitcoin.png"))
+ self.create_onchain_invoice_button.clicked.connect(lambda: self.create_invoice(False))
+ self.create_lightning_invoice_button = QPushButton(_('Lightning'))
+ self.create_lightning_invoice_button.setIcon(read_QIcon("lightning.png"))
+ self.create_lightning_invoice_button.clicked.connect(lambda: self.create_invoice(True))
+ self.create_lightning_invoice_button.setVisible(self.wallet.has_lightning())
+
+ self.receive_buttons = buttons = QHBoxLayout()
+ buttons.addWidget(self.clear_invoice_button)
+ buttons.addStretch(1)
+ buttons.addWidget(self.create_onchain_invoice_button)
+ buttons.addWidget(self.create_lightning_invoice_button)
+ grid.addLayout(buttons, 4, 1, 1, -1)
+
+ self.receive_e = QTextEdit()
+ self.receive_e.setFont(QFont(MONOSPACE_FONT))
+ self.receive_e.setReadOnly(True)
+ self.receive_e.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu)
+ self.receive_e.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
+ self.receive_e.textChanged.connect(self.update_receive_widgets)
+
+ self.receive_qr = QRCodeWidget(manual_size=True)
+
+ self.receive_help_text = WWLabel('')
+ self.receive_help_text.setLayout(QHBoxLayout())
+ self.receive_rebalance_button = QPushButton('Rebalance')
+ self.receive_rebalance_button.suggestion = None
+ self.receive_zeroconf_button = QPushButton(_('Accept'))
+ self.receive_zeroconf_button.clicked.connect(self.on_accept_zeroconf)
+
+ self.previous_request = None # type: Optional['Request']
+ self.confirmed_zeroconf_for_this_request = False # type: bool
+
+ def on_receive_rebalance():
+ if self.receive_rebalance_button.suggestion:
+ chan1, chan2, delta = self.receive_rebalance_button.suggestion
+ self.window.rebalance_dialog(chan1, chan2, amount_sat=delta)
+ self.receive_rebalance_button.clicked.connect(on_receive_rebalance)
+ self.receive_swap_button = QPushButton('Swap')
+ self.receive_swap_button.suggestion = None
+
+ def on_receive_swap():
+ if self.receive_swap_button.suggestion:
+ chan, swap_recv_amount_sat = self.receive_swap_button.suggestion
+ self.window.run_swap_dialog(is_reverse=True, recv_amount_sat_or_max=swap_recv_amount_sat, channels=[chan])
+ self.receive_swap_button.clicked.connect(on_receive_swap)
+ buttons = QHBoxLayout()
+ buttons.addWidget(self.receive_rebalance_button)
+ buttons.addWidget(self.receive_swap_button)
+ buttons.addWidget(self.receive_zeroconf_button)
+ vbox = QVBoxLayout()
+ vbox.addWidget(self.receive_help_text)
+ vbox.addLayout(buttons)
+ self.receive_help_widget = FramedWidget()
+ self.receive_help_widget.setVisible(False)
+ self.receive_help_widget.setLayout(vbox)
+
+ self.receive_widget = ReceiveWidget(
+ self, self.receive_e, self.receive_qr, self.receive_help_widget)
+ #self.receive_widget.mouseReleaseEvent = lambda x: self.toggle_receive_qr()
+
+ receive_widget_sp = QSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.MinimumExpanding)
+ receive_widget_sp.setRetainSizeWhenHidden(True)
+ self.receive_widget.setSizePolicy(receive_widget_sp)
+ self.receive_widget.setVisible(False)
+
+ self.receive_requests_label = QLabel(_('Requests'))
+ # with QDarkStyle, this label may partially cover the qrcode widget.
+ # setMaximumWidth prevents that
+ self.receive_requests_label.setMaximumWidth(400)
+ from .request_list import RequestList
+ self.request_list = RequestList(self)
+ # toolbar
+ self.toolbar, menu = self.request_list.create_toolbar_with_menu('')
+
+ self.toggle_qr_button = QPushButton('')
+ self.toggle_qr_button.setIcon(get_icon_qrcode())
+ self.toggle_qr_button.setToolTip(_('Switch between text and QR code view'))
+ self.toggle_qr_button.clicked.connect(self.toggle_receive_qr)
+ self.toggle_qr_button.setEnabled(False)
+ self.toolbar.insertWidget(2, self.toggle_qr_button)
+
+ # menu
+ self.qr_menu_action = menu.addToggle(_("Show detached QR code window"), self.window.toggle_qr_window)
+ menu.addAction(_("Import requests"), self.window.import_requests)
+ menu.addAction(_("Export requests"), self.window.export_requests)
+ menu.addAction(_("Delete expired requests"), self.request_list.delete_expired_requests)
+ self.toolbar_menu = menu
+
+ # layout
+ vbox_g = QVBoxLayout()
+ vbox_g.addLayout(grid)
+ vbox_g.addStretch()
+ hbox = QHBoxLayout()
+ hbox.addLayout(vbox_g)
+ hbox.addStretch()
+ hbox.addWidget(self.receive_widget, 1)
+
+ self.searchable_list = self.request_list
+ vbox = QVBoxLayout(self)
+ vbox.addLayout(self.toolbar)
+ vbox.addLayout(hbox)
+ vbox.addStretch()
+ vbox.addWidget(self.receive_requests_label)
+ vbox.addWidget(self.request_list)
+ vbox.setStretchFactor(hbox, 40)
+ vbox.setStretchFactor(self.request_list, 60)
+ self.request_list.update() # after parented and put into a layout, can update without flickering
+ self.update_expiry_text()
+
+ def update_expiry_text(self):
+ expiry = self.config.WALLET_PAYREQ_EXPIRY_SECONDS
+ text = pr_expiration_values()[expiry]
+ self.expiry_button.setText(text)
+
+ def expiry_dialog(self):
+ msg = ''.join([
+ _('Expiration period of your request.'), ' ',
+ _('This information is seen by the recipient if you send them a signed payment request.'),
+ '\n\n',
+ _('For on-chain requests, the address gets reserved until expiration. After that, it might get reused.'), ' ',
+ _('The bitcoin address never expires and will always be part of this electrum wallet.'), ' ',
+ _('You can reuse a bitcoin address any number of times but it is not good for your privacy.'),
+ '\n\n',
+ _('For Lightning requests, payments will not be accepted after the expiration.'),
+ ])
+ expiry = self.config.WALLET_PAYREQ_EXPIRY_SECONDS
+ choices = [ChoiceItem(key=exptime, label=label)
+ for (exptime, label) in pr_expiration_values().items()]
+ v = self.window.query_choice(msg, choices, title=_('Expiry'), default_key=expiry)
+ if v is None:
+ return
+ self.config.WALLET_PAYREQ_EXPIRY_SECONDS = v
+ self.update_expiry_text()
+
+ def on_tab_changed(self):
+ text, data, help_text, title = self.get_tab_data()
+ self.window.do_copy(text, title=title)
+ self.update_receive_qr_window()
+
+ def do_copy(self, e: 'QMouseEvent'):
+ if e.button() != Qt.MouseButton.LeftButton:
+ return
+ text, data, help_text, title = self.get_tab_data()
+ self.window.do_copy(text, title=title)
+
+ def toggle_receive_qr(self):
+ b = not self.config.GUI_QT_RECEIVE_TAB_QR_VISIBLE
+ self.config.GUI_QT_RECEIVE_TAB_QR_VISIBLE = b
+ self.update_receive_widgets()
+
+ def update_receive_widgets(self):
+ b = self.config.GUI_QT_RECEIVE_TAB_QR_VISIBLE
+ self.receive_widget.update_visibility(b, bool(self.receive_help_text.text()))
+
+ def update_current_request(self):
+ if len(self.request_list.selectionModel().selectedRows(0)) > 1:
+ key = None
+ else:
+ key = self.request_list.get_current_key()
+ req = self.wallet.get_request(key) if key else None
+ if req != self.previous_request:
+ self.previous_request = req
+ self.confirmed_zeroconf_for_this_request = False
+ if req is None:
+ self.receive_e.setText('')
+ self.addr = self.URI = self.lnaddr = ''
+ self.address_help = self.URI_help = self.ln_help = ''
+ return
+ help_texts = self.wallet.get_help_texts_for_receive_request(req)
+ self.addr = (req.get_address() or '') if not help_texts.address_is_error else ''
+ self.URI = (self.wallet.get_request_URI(req) or '') if not help_texts.URI_is_error else ''
+ self.lnaddr = self.wallet.get_bolt11_invoice(req) if not help_texts.ln_is_error else ''
+ self.address_help = help_texts.address_help
+ self.URI_help = help_texts.URI_help
+ self.ln_help = help_texts.ln_help
+ can_rebalance = help_texts.can_rebalance()
+ can_swap = help_texts.can_swap()
+ can_zeroconf = help_texts.can_zeroconf() if not self.confirmed_zeroconf_for_this_request else False
+ self.receive_rebalance_button.suggestion = help_texts.ln_rebalance_suggestion
+ self.receive_swap_button.suggestion = help_texts.ln_swap_suggestion
+ self.receive_rebalance_button.setVisible(can_rebalance)
+ self.receive_swap_button.setVisible(can_swap)
+ self.receive_rebalance_button.setEnabled(can_rebalance and self.window.num_tasks() == 0)
+ self.receive_swap_button.setEnabled(can_swap and self.window.num_tasks() == 0)
+ self.receive_zeroconf_button.setVisible(can_zeroconf)
+ self.receive_zeroconf_button.setEnabled(can_zeroconf)
+ text, data, help_text, title = self.get_tab_data()
+ if self.confirmed_zeroconf_for_this_request and help_texts.can_zeroconf():
+ help_text = ''
+ # set help before receive_e so we don't flicker from qr to help
+ self.receive_help_text.setText(help_text)
+ self.receive_e.setText(text)
+ self.receive_qr.setData(data)
+ for w in [self.receive_e, self.receive_qr]:
+ w.setEnabled(bool(text) and (not help_text or can_zeroconf))
+ w.setToolTip(help_text)
+ # macOS hack (similar to #4777)
+ self.receive_e.repaint()
+ # always show
+ self.receive_widget.setVisible(True)
+ self.toggle_qr_button.setEnabled(True)
+ self.update_receive_qr_window()
+
+ def on_accept_zeroconf(self):
+ self.receive_zeroconf_button.setVisible(False)
+ self.confirmed_zeroconf_for_this_request = True
+ self.receive_help_text.setText('')
+ self.update_receive_widgets()
+
+ def get_tab_data(self):
+ if self.URI:
+ out = self.URI, self.URI, self.URI_help, _('Bitcoin URI')
+ elif self.addr:
+ out = self.addr, self.addr, self.address_help, _('Address')
+ else:
+ # encode lightning invoices as uppercase so QR encoding can use
+ # alphanumeric mode; resulting in smaller QR codes
+ out = self.lnaddr, self.lnaddr.upper(), self.ln_help, _('Lightning Request')
+ return out
+
+ def update_receive_qr_window(self):
+ if self.window.qr_window and self.window.qr_window.isVisible():
+ text, data, help_text, title = self.get_tab_data()
+ self.window.qr_window.qrw.setData(data)
+
+ def create_invoice(self, is_lightning: bool):
+ amount_sat = self.receive_amount_e.get_amount()
+ message = self.receive_message_e.text()
+ expiry = self.config.WALLET_PAYREQ_EXPIRY_SECONDS
+ if is_lightning:
+ address = None
+ else:
+ if amount_sat and amount_sat < self.wallet.dust_threshold():
+ self.show_error(_('Amount too small to be received onchain'))
+ return
+ address = self.get_bitcoin_address_for_request(amount_sat)
+ if not address:
+ return
+ self.window.address_list.update()
+
+ # generate even if we cannot receive
+ try:
+ key = self.wallet.create_request(amount_sat, message, expiry, address)
+ except InvoiceError as e:
+ self.show_error(_('Error creating payment request') + ':\n' + str(e))
+ return
+ except Exception as e:
+ self.logger.exception('Error adding payment request')
+ self.show_error(_('Error adding payment request') + ':\n' + repr(e))
+ return
+ assert key is not None
+ self.window.address_list.refresh_all()
+ self.request_list.update()
+ self.request_list.set_current_key(key)
+ # clear request fields
+ self.receive_amount_e.setText('')
+ self.receive_message_e.setText('')
+ # copy current tab to clipboard
+ self.on_tab_changed()
+
+ def get_bitcoin_address_for_request(self, amount) -> Optional[str]:
+ addr = self.wallet.get_unused_address()
+ if addr is None:
+ 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.'), '\n\n',
+ _('Creating a new payment request will reuse one of your addresses and overwrite an existing request. Continue anyway?'),
+ ]
+ 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)
+ return addr
+
+ def do_clear(self):
+ self.receive_e.setText('')
+ self.addr = self.URI = self.lnaddr = ''
+ self.address_help = self.URI_help = self.ln_help = ''
+ self.receive_widget.setVisible(False)
+ self.toggle_qr_button.setEnabled(False)
+ self.receive_message_e.setText('')
+ self.receive_amount_e.setAmount(None)
+ self.request_list.clearSelection()
+
+
+class ReceiveWidget(QWidget):
+ min_size = QSize(200, 200)
+
+ def __init__(self, receive_tab: 'ReceiveTab', textedit: QWidget, qr: QWidget, help_widget: QWidget):
+ QWidget.__init__(self)
+ self.textedit = textedit
+ self.qr = qr
+ self.help_widget = help_widget
+ self.setMinimumSize(self.min_size)
+
+ for w in [textedit, qr]:
+ w.mousePressEvent = receive_tab.do_copy
+ w.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
+
+ textedit.setFocusPolicy(Qt.FocusPolicy.NoFocus)
+ if isinstance(help_widget, QLabel):
+ help_widget.setFrameStyle(QFrame.Shape.StyledPanel)
+ help_widget.setStyleSheet("QLabel {border:1px solid gray; border-radius:2px; }")
+
+ hbox = QHBoxLayout()
+ hbox.addStretch()
+ hbox.addWidget(textedit)
+ hbox.addWidget(help_widget)
+ hbox.addWidget(qr)
+
+ vbox = QVBoxLayout()
+ vbox.addLayout(hbox)
+ vbox.addStretch()
+
+ self.setLayout(vbox)
+
+ def update_visibility(self, is_qr: bool, show_help: bool):
+ if str(self.textedit.toPlainText()) and not show_help:
+ self.help_widget.setVisible(False)
+ self.textedit.setVisible(not is_qr)
+ self.qr.setVisible(is_qr)
+ else:
+ self.show_help()
+
+ def show_help(self):
+ self.help_widget.setVisible(True)
+ self.textedit.setVisible(False)
+ self.qr.setVisible(False)
+
+ def resizeEvent(self, e):
+ # keep square aspect ratio when resized
+ size = e.size()
+ margin = 10
+ x = min(size.height(), size.width()) - margin
+ for w in [self.textedit, self.qr, self.help_widget]:
+ w.setFixedWidth(x)
+ w.setFixedHeight(x)
+ return super().resizeEvent(e)
+
+
+class FramedWidget(QFrame):
+ def __init__(self):
+ QFrame.__init__(self)
+ self.setFrameStyle(QFrame.Shape.StyledPanel)
+ self.setStyleSheet("FramedWidget {border:1px solid gray; border-radius:2px; }")
diff --git a/electrum/gui/qt/request_list.py b/electrum/gui/qt/request_list.py
new file mode 100644
index 000000000000..9e66fcc42a71
--- /dev/null
+++ b/electrum/gui/qt/request_list.py
@@ -0,0 +1,228 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2015 Thomas Voegtlin
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import enum
+from typing import Optional, TYPE_CHECKING
+
+from PyQt6.QtGui import QStandardItemModel, QStandardItem
+from PyQt6.QtWidgets import QMenu, QAbstractItemView
+from PyQt6.QtCore import Qt, QItemSelectionModel, QModelIndex
+
+from electrum.i18n import _
+from electrum.util import format_time
+from electrum.plugin import run_hook
+
+from .util import pr_icons, read_QIcon
+from .my_treeview import MyTreeView, MySortModel
+
+if TYPE_CHECKING:
+ from .receive_tab import ReceiveTab
+
+
+ROLE_REQUEST_TYPE = Qt.ItemDataRole.UserRole
+ROLE_KEY = Qt.ItemDataRole.UserRole + 1
+ROLE_SORT_ORDER = Qt.ItemDataRole.UserRole + 2
+
+
+class RequestList(MyTreeView):
+ key_role = ROLE_KEY
+
+ class Columns(MyTreeView.BaseColumnsEnum):
+ DATE = enum.auto()
+ DESCRIPTION = enum.auto()
+ AMOUNT = enum.auto()
+ STATUS = enum.auto()
+ ADDRESS = enum.auto()
+ LN_RHASH = enum.auto()
+
+ headers = {
+ Columns.DATE: _('Date'),
+ Columns.DESCRIPTION: _('Description'),
+ Columns.AMOUNT: _('Amount'),
+ Columns.STATUS: _('Status'),
+ Columns.ADDRESS: _('Address'),
+ Columns.LN_RHASH: 'LN RHASH',
+ }
+ filter_columns = [
+ Columns.DATE, Columns.DESCRIPTION, Columns.AMOUNT,
+ Columns.ADDRESS, Columns.LN_RHASH,
+ ]
+
+ def __init__(self, receive_tab: 'ReceiveTab'):
+ window = receive_tab.window
+ super().__init__(
+ main_window=window,
+ stretch_column=self.Columns.DESCRIPTION,
+ )
+ self.wallet = window.wallet
+ self.receive_tab = receive_tab
+ 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.selectionModel().currentRowChanged.connect(self.item_changed)
+ self.selectionModel().selectionChanged.connect(self.selection_changed)
+ self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
+
+ def set_current_key(self, key):
+ for i in range(self.model().rowCount()):
+ item = self.model().index(i, self.Columns.DATE)
+ row_key = item.data(ROLE_KEY)
+ if key == row_key:
+ self.selectionModel().setCurrentIndex(
+ item, QItemSelectionModel.SelectionFlag.SelectCurrent | QItemSelectionModel.SelectionFlag.Rows)
+ break
+
+ def get_current_key(self):
+ return self.get_role_data_for_current_item(col=self.Columns.DATE, role=ROLE_KEY)
+
+ def selection_changed(self, selected, deselected):
+ self.receive_tab.update_current_request()
+
+ def item_changed(self, idx: Optional[QModelIndex]):
+ if idx is None:
+ self.receive_tab.update_current_request()
+ return
+ if not idx.isValid():
+ return
+ item = self.item_from_index(idx.siblingAtColumn(self.Columns.DATE))
+ key = item.data(ROLE_KEY)
+ req = self.wallet.get_request(key)
+ if req is None:
+ self.update()
+ self.receive_tab.update_current_request()
+
+ def clearSelection(self):
+ super().clearSelection()
+ self.selectionModel().clearCurrentIndex()
+
+ def refresh_row(self, key, row):
+ assert row is not None
+ model = self.std_model
+ request = self.wallet.get_request(key)
+ if request is None:
+ return
+ status_item = model.item(row, self.Columns.STATUS)
+ status = self.wallet.get_invoice_status(request)
+ status_str = request.get_status_str(status)
+ status_item.setText(status_str)
+ status_item.setIcon(read_QIcon(pr_icons.get(status)))
+
+ def update(self):
+ current_key = self.get_current_key()
+ # not calling maybe_defer_update() as it interferes with conditional-visibility
+ self.proxy.setDynamicSortFilter(False) # temp. disable re-sorting after every change
+ self.std_model.clear()
+ self.update_headers(self.__class__.headers)
+ self.set_visibility_of_columns()
+ for req in self.wallet.get_unpaid_requests():
+ key = req.get_id()
+ status = self.wallet.get_invoice_status(req)
+ status_str = req.get_status_str(status)
+ timestamp = req.get_time()
+ amount = req.get_amount_sat()
+ message = req.get_message()
+ date = format_time(timestamp)
+ amount_str = self.main_window.format_amount(amount) if amount else ""
+ amount_str_nots = self.main_window.format_amount(amount, add_thousands_sep=False) if amount else ""
+ labels = [""] * len(self.Columns)
+ labels[self.Columns.DATE] = date
+ labels[self.Columns.DESCRIPTION] = message
+ labels[self.Columns.AMOUNT] = amount_str
+ labels[self.Columns.STATUS] = status_str
+ labels[self.Columns.ADDRESS] = req.get_address() or ""
+ labels[self.Columns.LN_RHASH] = req.rhash if req.is_lightning() else ""
+ items = [QStandardItem(e) for e in labels]
+ self.set_editability(items)
+ #items[self.Columns.DATE].setData(request_type, ROLE_REQUEST_TYPE)
+ items[self.Columns.DATE].setData(key, ROLE_KEY)
+ items[self.Columns.DATE].setData(timestamp, ROLE_SORT_ORDER)
+ items[self.Columns.DATE].setIcon(read_QIcon("lightning" if req.is_lightning() else "bitcoin"))
+ items[self.Columns.AMOUNT].setData(amount_str_nots.strip(), self.ROLE_CLIPBOARD_DATA)
+ items[self.Columns.STATUS].setIcon(read_QIcon(pr_icons.get(status)))
+ self.std_model.insertRow(self.std_model.rowCount(), items)
+ self.filter()
+ self.proxy.setDynamicSortFilter(True)
+ # sort requests by date
+ self.sortByColumn(self.Columns.DATE, Qt.SortOrder.DescendingOrder)
+ self.hide_if_empty()
+ if current_key is not None:
+ self.set_current_key(current_key)
+
+ def hide_if_empty(self):
+ b = self.std_model.rowCount() > 0
+ self.setVisible(b)
+ self.receive_tab.receive_requests_label.setVisible(b)
+ if not b:
+ # list got hidden, so selected item should also be cleared:
+ self.item_changed(None)
+
+ def create_menu(self, position):
+ items = self.selected_in_column(0)
+ if len(items) > 1:
+ keys = [item.data(ROLE_KEY) for item in items]
+ menu = QMenu(self)
+ menu.addAction(_("Delete requests"), lambda: self.delete_requests(keys))
+ menu.exec(self.viewport().mapToGlobal(position))
+ return
+ idx = self.indexAt(position)
+ item = self.item_from_index(idx.siblingAtColumn(self.Columns.DATE))
+ if not item:
+ return
+ key = item.data(ROLE_KEY)
+ req = self.wallet.get_request(key)
+ if req is None:
+ self.update()
+ return
+ menu = QMenu(self)
+ copy_menu = self.add_copy_menu(menu, idx)
+ if req.get_address():
+ copy_menu.addAction(_("Address"), lambda: self.main_window.do_copy(req.get_address(), title='Bitcoin Address'))
+ if URI := self.wallet.get_request_URI(req):
+ copy_menu.addAction(_("Bitcoin URI"), lambda: self.main_window.do_copy(URI, title='Bitcoin URI'))
+ if req.is_lightning():
+ copy_menu.addAction(_("Lightning Request"), lambda: self.main_window.do_copy(self.wallet.get_bolt11_invoice(req), title='Lightning Request'))
+ #if 'view_url' in req:
+ # menu.addAction(_("View in web browser"), lambda: webopen(req['view_url']))
+ menu.addAction(_("Delete"), lambda: self.delete_requests([key]))
+ run_hook('receive_list_menu', self.main_window, menu, key)
+ self.open_menu(menu, position)
+
+ def delete_requests(self, keys):
+ self.wallet.delete_requests(keys)
+ self.update()
+ self.receive_tab.do_clear()
+
+ def delete_expired_requests(self):
+ keys = self.wallet.delete_expired_requests()
+ self.update()
+ self.receive_tab.do_clear()
+
+ def set_visibility_of_columns(self):
+ def set_visible(col: int, b: bool):
+ self.showColumn(col) if b else self.hideColumn(col)
+ set_visible(self.Columns.ADDRESS, False)
+ set_visible(self.Columns.LN_RHASH, False)
diff --git a/electrum/gui/qt/seed_dialog.py b/electrum/gui/qt/seed_dialog.py
new file mode 100644
index 000000000000..0b0da25ae3bb
--- /dev/null
+++ b/electrum/gui/qt/seed_dialog.py
@@ -0,0 +1,429 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2013 ecdsa@github
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from typing import TYPE_CHECKING
+
+from PyQt6.QtCore import Qt, pyqtSignal
+from PyQt6.QtGui import QPixmap
+from PyQt6.QtWidgets import (QVBoxLayout, QCheckBox, QHBoxLayout, QLineEdit,
+ QLabel, QCompleter, QDialog, QStyledItemDelegate,
+ QWidget, QPushButton)
+
+from electrum.i18n import _
+from electrum.mnemonic import Mnemonic, calc_seed_type, is_any_2fa_seed_type
+from electrum import old_mnemonic
+from electrum import slip39
+from electrum.util import ChoiceItem
+
+from .util import (
+ Buttons, OkButton, WWLabel, ButtonsTextEdit, icon_path, EnterButton,
+ CloseButton, WindowModalDialog, ColorScheme, font_height, ChoiceWidget,
+)
+from .qrtextedit import ShowQRTextEdit, ScanQRTextEdit
+from .completion_text_edit import CompletionTextEdit
+
+if TYPE_CHECKING:
+ from electrum.simple_config import SimpleConfig
+
+
+MSG_PASSPHRASE_WARN_ISSUE4566 = _("Warning") + ": "\
+ + _("You have multiple consecutive whitespaces or leading/trailing "
+ "whitespaces in your passphrase.") + " " \
+ + _("This is discouraged.") + " " \
+ + _("Due to a bug, old versions of Electrum will NOT be creating the "
+ "same wallet as newer versions or other software.")
+
+
+def seed_warning_msg(seed):
+ return ''.join([
+ "
",
+ _("Please save these {0} words on paper (order is important). "),
+ _("This seed will allow you to recover your wallet in case "
+ "of computer failure."),
+ "
",
+ "" + _("WARNING") + ":",
+ "
",
+ "
" + _("Never disclose your seed.") + "
",
+ "
" + _("Never type it on a website.") + "
",
+ "
" + _("Do not store it electronically.") + "
",
+ "
"
+ ]).format(len(seed.split()))
+
+
+class SeedWidget(QWidget):
+
+ updated = pyqtSignal()
+ validChanged = pyqtSignal([bool], arguments=['valid'])
+
+ def __init__(
+ self,
+ seed=None,
+ title=None,
+ icon=True,
+ msg=None,
+ options=None,
+ is_seed=None, # only used for electrum seeds
+ passphrase=None,
+ parent=None,
+ for_seed_words=True,
+ *,
+ config: 'SimpleConfig',
+ ):
+ QWidget.__init__(self, parent)
+ vbox = QVBoxLayout()
+ self.setLayout(vbox)
+
+ self.options = options
+ self.config = config
+ self.msg = msg
+
+ if options:
+ self.seed_types = [
+ ChoiceItem(key=stype, label=label) for stype, label in (
+ ('electrum', 'Electrum'),
+ ('bip39', _('BIP39 seed')),
+ ('slip39', _('SLIP39 seed')),
+ )
+ if stype in self.options
+ ]
+ assert len(self.seed_types)
+ self.seed_type = self.seed_types[0].key
+ else:
+ self.seed_type = 'electrum'
+
+ self.is_seed = is_seed
+
+ if title:
+ vbox.addWidget(WWLabel(title))
+ if seed: # "read only", we already have the text
+ if for_seed_words:
+ self.seed_e = ButtonsTextEdit()
+ else: # e.g. xpub
+ self.seed_e = ShowQRTextEdit(config=self.config)
+ self.seed_e.addCopyButton()
+ self.seed_e.setReadOnly(True)
+ self.seed_e.setText(seed)
+ else: # we expect user to enter text
+ assert for_seed_words
+ self.seed_e = CompletionTextEdit()
+ self.seed_e.setTabChangesFocus(False) # so that tab auto-completes
+ self.seed_e.textChanged.connect(self.on_edit)
+ self.initialize_completer()
+
+ self.seed_e.setMaximumHeight(max(75, 5 * font_height()))
+ hbox = QHBoxLayout()
+ if icon:
+ logo = QLabel()
+ logo.setPixmap(QPixmap(icon_path("seed.png"))
+ .scaledToWidth(64, mode=Qt.TransformationMode.SmoothTransformation))
+ logo.setMaximumWidth(60)
+ hbox.addWidget(logo)
+ hbox.addWidget(self.seed_e)
+ vbox.addLayout(hbox)
+ hbox = QHBoxLayout()
+ hbox.addStretch(1)
+ self.seed_type_label = QLabel('')
+ hbox.addWidget(self.seed_type_label)
+
+ # options
+ self.is_ext = False
+ if options:
+ opt_button = EnterButton(_('Options'), self.seed_options)
+ hbox.addWidget(opt_button)
+ vbox.addLayout(hbox)
+ if passphrase:
+ hbox = QHBoxLayout()
+ passphrase_e = QLineEdit()
+ passphrase_e.setText(passphrase)
+ passphrase_e.setReadOnly(True)
+ hbox.addWidget(QLabel(_("Your seed extension is") + ':'))
+ hbox.addWidget(passphrase_e)
+ vbox.addLayout(hbox)
+
+ # slip39 shares
+ self.slip39_mnemonic_index = 0
+ self.slip39_mnemonics = [""]
+ self.slip39_seed = None
+ self.slip39_current_mnemonic_invalid = None
+ hbox = QHBoxLayout()
+ hbox.addStretch(1)
+ self.prev_share_btn = QPushButton(_("Previous share"))
+ self.prev_share_btn.clicked.connect(self.on_prev_share)
+ hbox.addWidget(self.prev_share_btn)
+ self.next_share_btn = QPushButton(_("Next share"))
+ self.next_share_btn.clicked.connect(self.on_next_share)
+ hbox.addWidget(self.next_share_btn)
+ self.update_share_buttons()
+ vbox.addLayout(hbox)
+
+ vbox.addStretch(1)
+ self.seed_status = WWLabel('')
+ vbox.addWidget(self.seed_status)
+ self.seed_warning = WWLabel('')
+ if msg:
+ self.seed_warning.setText(seed_warning_msg(seed))
+ else:
+ self.update_seed_warning()
+
+ vbox.addWidget(self.seed_warning)
+
+ def seed_options(self):
+ dialog = QDialog()
+ dialog.setWindowTitle(_("Seed Options"))
+ vbox = QVBoxLayout(dialog)
+
+ if 'ext' in self.options:
+ cb_ext = QCheckBox(_('Extend this seed with custom words'))
+ cb_ext.setChecked(self.is_ext)
+ vbox.addWidget(cb_ext)
+
+ def on_selected(idx):
+ self.seed_type = seed_type_choice.selected_key
+ self.slip39_current_mnemonic_invalid = None
+ self.seed_status.setText('')
+ self.update_seed_warning()
+ self.on_edit()
+ self.update_share_buttons()
+ self.initialize_completer()
+
+ if len(self.seed_types) > 1:
+ seed_type_choice = ChoiceWidget(message=_('Seed type'), choices=self.seed_types, default_key=self.seed_type)
+ seed_type_choice.itemSelected.connect(on_selected)
+ vbox.addWidget(seed_type_choice)
+
+ vbox.addLayout(Buttons(OkButton(dialog)))
+
+ if not dialog.exec():
+ return None
+
+ if 'ext' in self.options:
+ self.is_ext = cb_ext.isChecked()
+ if len(self.seed_types) > 1:
+ self.seed_type = seed_type_choice.selected_key
+
+ self.update_seed_warning()
+ self.updated.emit()
+
+ def update_seed_warning(self):
+ if self.msg:
+ return
+
+ if self.seed_type == 'bip39':
+ message = ' '.join([
+ '' + _('Warning') + ': ',
+ _('BIP39 seeds can be imported in Electrum, so that users can access funds locked in other wallets.'),
+ _('However, we do not generate BIP39 seeds, because they do not meet our safety standard.'),
+ _('BIP39 seeds do not include a version number, which compromises compatibility with future software.'),
+ _('We do not guarantee that BIP39 imports will always be supported in Electrum.'),
+ ])
+ elif self.seed_type == 'slip39':
+ message = ' '.join([
+ '' + _('Warning') + ': ',
+ _('SLIP39 seeds can be imported in Electrum, so that users can access funds locked in other wallets.'),
+ _('However, we do not generate SLIP39 seeds.'),
+ ])
+ else:
+ message = ''
+
+ self.seed_warning.setText(message)
+
+ def initialize_completer(self):
+ if self.seed_type != 'slip39':
+ bip39_english_list = Mnemonic('en').wordlist
+ old_list = old_mnemonic.wordlist
+ only_old_list = set(old_list) - set(bip39_english_list)
+ self.wordlist = list(bip39_english_list) + list(only_old_list) # concat both lists
+ self.wordlist.sort()
+
+ class CompleterDelegate(QStyledItemDelegate):
+ def initStyleOption(self, option, index):
+ super().initStyleOption(option, index)
+ # Some people complained that due to merging the two word lists,
+ # it is difficult to restore from a metal backup, as they planned
+ # to rely on the "4 letter prefixes are unique in bip39 word list" property.
+ # So we color words that are only in old list.
+ if option.text in only_old_list:
+ # yellow bg looks ~ok on both light/dark theme, regardless if (un)selected
+ option.backgroundBrush = ColorScheme.YELLOW.as_color(background=True)
+
+ delegate = CompleterDelegate(self.seed_e)
+ else:
+ self.wordlist = list(slip39.get_wordlist())
+ delegate = None
+
+ self.completer = QCompleter(self.wordlist)
+ if delegate:
+ self.completer.popup().setItemDelegate(delegate)
+ self.seed_e.set_completer(self.completer)
+
+ def get_seed_words(self):
+ return self.seed_e.text().split()
+
+ def get_seed(self):
+ if self.seed_type != 'slip39':
+ return ' '.join(self.get_seed_words())
+ else:
+ return self.slip39_seed
+
+ def on_edit(self):
+ s = ' '.join(self.get_seed_words())
+ if self.seed_type == 'bip39':
+ from electrum.keystore import bip39_is_checksum_valid
+ is_checksum, is_wordlist = bip39_is_checksum_valid(s)
+ label = ''
+ valid = bool(s)
+ if valid:
+ label = ('' if is_checksum else _('BIP39 checksum failed')) if is_wordlist else _('Unknown BIP39 wordlist')
+ elif self.seed_type == 'slip39':
+ self.slip39_mnemonics[self.slip39_mnemonic_index] = s
+ try:
+ slip39.decode_mnemonic(s)
+ except slip39.Slip39Error as e:
+ share_status = str(e)
+ current_mnemonic_invalid = True
+ else:
+ share_status = _('Valid.')
+ current_mnemonic_invalid = False
+
+ label = _('SLIP39 share') + ' #%d: %s' % (self.slip39_mnemonic_index + 1, share_status)
+
+ # No need to process mnemonics if the current mnemonic remains invalid after editing.
+ if not (self.slip39_current_mnemonic_invalid and current_mnemonic_invalid):
+ self.slip39_seed, seed_status = slip39.process_mnemonics(self.slip39_mnemonics)
+ self.seed_status.setText(seed_status)
+ self.slip39_current_mnemonic_invalid = current_mnemonic_invalid
+
+ valid = self.slip39_seed is not None
+ self.update_share_buttons()
+ else:
+ valid = self.is_seed(s)
+ t = calc_seed_type(s)
+ label = _('Seed Type') + ': ' + t if t else ''
+ if t and not valid: # electrum seed, but does not conform to dialog rules
+ wiztype_fullname = _('Wallet with two-factor authentication') if is_any_2fa_seed_type(t) else _("Standard wallet")
+ msg = ' '.join([
+ '' + _('Warning') + ': ',
+ _("Looks like you have entered a valid seed of type '{}' but this dialog does not support such seeds.").format(t),
+ _("If unsure, try restoring as '{}'.").format(wiztype_fullname),
+ ])
+ self.seed_warning.setText(msg)
+ else:
+ self.seed_warning.setText("")
+
+ self.seed_type_label.setText(label)
+ self.validChanged.emit(valid)
+
+ # disable suggestions if user already typed an unknown word
+ for word in self.get_seed_words()[:-1]:
+ if word not in self.wordlist:
+ self.seed_e.disable_suggestions()
+ return
+ self.seed_e.enable_suggestions()
+
+ def update_share_buttons(self):
+ if self.seed_type != 'slip39':
+ self.prev_share_btn.hide()
+ self.next_share_btn.hide()
+ return
+
+ finished = self.slip39_seed is not None
+ self.prev_share_btn.show()
+ self.next_share_btn.show()
+ self.prev_share_btn.setEnabled(self.slip39_mnemonic_index != 0)
+ self.next_share_btn.setEnabled(
+ # already pressed "prev" and undoing that:
+ self.slip39_mnemonic_index < len(self.slip39_mnemonics) - 1
+ # finished entering latest share and starting new one:
+ or (bool(self.seed_e.text().strip()) and not self.slip39_current_mnemonic_invalid and not finished)
+ )
+
+ def on_prev_share(self):
+ if not self.slip39_mnemonics[self.slip39_mnemonic_index]:
+ del self.slip39_mnemonics[self.slip39_mnemonic_index]
+
+ self.slip39_mnemonic_index -= 1
+ self.seed_e.setText(self.slip39_mnemonics[self.slip39_mnemonic_index])
+ self.slip39_current_mnemonic_invalid = None
+
+ def on_next_share(self):
+ if not self.slip39_mnemonics[self.slip39_mnemonic_index]:
+ del self.slip39_mnemonics[self.slip39_mnemonic_index]
+ else:
+ self.slip39_mnemonic_index += 1
+
+ if len(self.slip39_mnemonics) <= self.slip39_mnemonic_index:
+ self.slip39_mnemonics.append("")
+ self.seed_e.setFocus()
+ self.seed_e.setText(self.slip39_mnemonics[self.slip39_mnemonic_index])
+ self.slip39_current_mnemonic_invalid = None
+
+
+class KeysWidget(QWidget):
+
+ validChanged = pyqtSignal([bool], arguments=['valid'])
+
+ def __init__(
+ self,
+ parent=None,
+ header_layout=None,
+ is_valid=None,
+ allow_multi=False,
+ *,
+ config: 'SimpleConfig',
+ ):
+ QWidget.__init__(self, parent)
+ vbox = QVBoxLayout()
+ self.setLayout(vbox)
+
+ self.is_valid = is_valid
+ self.text_e = ScanQRTextEdit(allow_multi=allow_multi, config=config)
+ self.text_e.textChanged.connect(self.on_edit)
+ if isinstance(header_layout, str):
+ vbox.addWidget(WWLabel(header_layout))
+ else:
+ vbox.addLayout(header_layout)
+ vbox.addWidget(self.text_e)
+
+ def get_text(self):
+ return self.text_e.text()
+
+ def on_edit(self):
+ try:
+ valid = self.is_valid(self.get_text())
+ except Exception as e:
+ valid = False
+ self.validChanged.emit(valid)
+
+
+class SeedDialog(WindowModalDialog):
+
+ def __init__(self, parent, seed, passphrase, *, config: 'SimpleConfig'):
+ WindowModalDialog.__init__(self, parent, ('Electrum - ' + _('Seed')))
+ self.setMinimumWidth(400)
+ vbox = QVBoxLayout(self)
+ title = _("Your wallet generation seed is:")
+ seed_widget = SeedWidget(title=title, seed=seed, msg=True, passphrase=passphrase, config=config)
+ vbox.addWidget(seed_widget)
+ vbox.addLayout(Buttons(CloseButton(self)))
diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py
new file mode 100644
index 000000000000..cdb97cceb76d
--- /dev/null
+++ b/electrum/gui/qt/send_tab.py
@@ -0,0 +1,1005 @@
+# Copyright (C) 2022 The Electrum developers
+# Distributed under the MIT software license, see the accompanying
+# file LICENCE or http://www.opensource.org/licenses/mit-license.php
+
+from decimal import Decimal
+from typing import Optional, TYPE_CHECKING, Sequence, List, Callable, Union, Mapping
+import urllib.parse
+
+from PyQt6.QtCore import pyqtSignal, QPoint, Qt
+from PyQt6.QtWidgets import (QLabel, QVBoxLayout, QGridLayout, QHBoxLayout,
+ QWidget, QToolTip, QPushButton, QApplication, QSlider)
+
+from electrum.i18n import _
+from electrum.logging import Logger
+from electrum.bitcoin import DummyAddress
+from electrum.plugin import run_hook
+from electrum.util import (
+ NotEnoughFunds, NoDynamicFeeEstimates, parse_max_spend, UserCancelled, ChoiceItem,
+ UserFacingException,
+)
+from electrum.lnutil import RECEIVED
+from electrum.invoices import PR_PAID, Invoice, PR_BROADCASTING, PR_BROADCAST
+from electrum.transaction import Transaction, PartialTxInput, PartialTxOutput
+from electrum.network import TxBroadcastError, BestEffortRequestFailed
+from electrum.payment_identifier import (PaymentIdentifierType, PaymentIdentifier,
+ invoice_from_payment_identifier,
+ PaymentIdentifierState)
+from electrum.submarine_swaps import SwapServerError
+from electrum.fee_policy import FeePolicy, FixedFeePolicy
+from electrum.lnurl import LNURL3Data, request_lnurl_withdraw_callback, LNURLError
+
+from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit
+from .paytoedit import InvalidPaymentIdentifier
+from .util import (WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton, char_width_in_lineedit,
+ get_icon_camera, read_QIcon, ColorScheme, IconLabel, Spinner, Buttons, WWLabel,
+ add_input_actions_to_context_menu, WindowModalDialog, OkButton, CancelButton)
+from .invoice_list import InvoiceList
+
+if TYPE_CHECKING:
+ from .main_window import ElectrumWindow
+
+
+class SendTab(QWidget, MessageBoxMixin, Logger):
+
+ resolve_done_signal = pyqtSignal(object)
+ finalize_done_signal = pyqtSignal(object)
+
+ def __init__(self, window: 'ElectrumWindow'):
+ QWidget.__init__(self, window)
+ Logger.__init__(self)
+ self.app = QApplication.instance()
+ self.window = window
+ self.wallet = window.wallet
+ self.fx = window.fx
+ self.config = window.config
+ self.network = window.network
+
+ self.format_amount_and_units = window.format_amount_and_units
+ self.format_amount = window.format_amount
+ self.base_unit = window.base_unit
+
+ self.pending_invoice = None
+
+ # A 4-column grid layout. All the stretch is in the last column.
+ # The exchange rate plugin adds a fiat widget in column 2
+ self.send_grid = grid = QGridLayout()
+ grid.setSpacing(8)
+ grid.setColumnStretch(3, 1)
+
+ from .paytoedit import PayToEdit
+ self.amount_e = BTCAmountEdit(self.window.get_decimal_point)
+ self.payto_e = PayToEdit(self)
+ msg = (_("Recipient of the funds.")
+ + "\n\n"
+ + _("This field can contain:") + "\n"
+ + _("- a Bitcoin address or BIP21 URI") + "\n"
+ + _("- a Lightning invoice") + "\n"
+ + _("- a label from your list of contacts") + "\n"
+ + _("- an openalias") + "\n"
+ + _("- an arbitrary on-chain script, e.g.:") + " script(OP_RETURN deadbeef)" + "\n"
+ + "\n"
+ + _("You can also pay to many outputs in a single transaction, "
+ "specifying one output per line.") + "\n" + _("Format: address, amount") + "\n"
+ + _("To set the amount to 'max', use the '!' special character.") + "\n"
+ + _("Integers weights can also be used in conjunction with '!', "
+ "e.g. set one amount to '2!' and another to '3!' to split your coins 40-60."))
+ self.payto_label = HelpLabel(_('Pay to'), msg)
+ grid.addWidget(self.payto_label, 0, 0, Qt.AlignmentFlag.AlignLeft)
+ grid.addWidget(self.payto_e, 0, 1, 1, 4)
+
+ #completer = QCompleter()
+ #completer.setCaseSensitivity(False)
+ #self.payto_e.set_completer(completer)
+ #completer.setModel(self.window.completions)
+
+ msg = _('Description of the transaction (not mandatory).') + '\n\n' \
+ + _(
+ 'The description is not sent to the recipient of the funds. It is stored in your wallet file, and displayed in the \'History\' tab.')
+ description_label = HelpLabel(_('Description'), msg)
+ grid.addWidget(description_label, 1, 0)
+ self.message_e = SizedFreezableLineEdit(width=600)
+ grid.addWidget(self.message_e, 1, 1, 1, 4)
+
+ msg = _('Comment for recipient')
+ self.comment_label = HelpLabel(_('Comment'), msg)
+ grid.addWidget(self.comment_label, 2, 0)
+ self.comment_e = SizedFreezableLineEdit(width=600)
+ grid.addWidget(self.comment_e, 2, 1, 1, 4)
+ self.comment_label.hide()
+ self.comment_e.hide()
+
+ msg = (_('The amount to be received by the recipient.') + ' '
+ + _('Fees are paid by the sender.') + '\n\n'
+ + _('Note that if you have frozen some of your addresses, the available funds will be lower than your total balance.') + '\n\n'
+ + _('Keyboard shortcut: type "!" to send all your coins.'))
+ amount_label = HelpLabel(_('Amount'), msg)
+ grid.addWidget(amount_label, 3, 0)
+
+ amount_widgets = QHBoxLayout()
+ amount_widgets.addWidget(self.amount_e)
+
+ self.fiat_send_e = AmountEdit(self.fx.get_currency if self.fx else '')
+ if not self.fx or not self.fx.is_enabled():
+ self.fiat_send_e.setVisible(False)
+ amount_widgets.addWidget(self.fiat_send_e)
+ self.amount_e.frozen.connect(
+ lambda: self.fiat_send_e.setFrozen(self.amount_e.isReadOnly()))
+
+ self.window.connect_fields(self.amount_e, self.fiat_send_e)
+
+ self.max_button = EnterButton(_("Max"), self.spend_max)
+ btn_width = 10 * char_width_in_lineedit()
+ self.max_button.setFixedWidth(btn_width)
+ self.max_button.setCheckable(True)
+ self.max_button.setEnabled(False)
+ amount_widgets.addWidget(self.max_button)
+ amount_widgets.addStretch(1)
+ grid.addLayout(amount_widgets, 3, 1, 1, -1)
+
+ invoice_error_icon = read_QIcon("warning.png")
+ self.invoice_error = IconLabel(reverse=True, hide_if_empty=True)
+ self.invoice_error.setIcon(invoice_error_icon)
+ grid.addWidget(self.invoice_error, 3, 4, Qt.AlignmentFlag.AlignRight)
+
+ self.paste_button = QPushButton(_('Paste'))
+ self.paste_button.clicked.connect(self.do_paste)
+ self.paste_button.setIcon(read_QIcon('copy.png'))
+ self.paste_button.setToolTip(_('Paste invoice from clipboard'))
+ self.paste_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
+
+ self.spinner = Spinner()
+ grid.addWidget(self.spinner, 0, 1, 1, 4, Qt.AlignmentFlag.AlignRight)
+
+ self.save_button = EnterButton(_("Save"), self.do_save_invoice)
+ self.save_button.setEnabled(False)
+ self.send_button = EnterButton(_("Pay") + "...", self.do_pay_or_get_invoice)
+ self.send_button.setEnabled(False)
+ self.clear_button = EnterButton(_("Clear"), self.do_clear)
+
+ buttons = QHBoxLayout()
+ buttons.addWidget(self.paste_button)
+ buttons.addWidget(self.clear_button)
+ buttons.addStretch(1)
+ buttons.addWidget(self.save_button)
+ buttons.addWidget(self.send_button)
+ grid.addLayout(buttons, 6, 1, 1, 4)
+
+ self.amount_e.shortcut.connect(self.spend_max)
+
+ def reset_max(text):
+ self.max_button.setChecked(False)
+
+ self.amount_e.textChanged.connect(self.on_amount_changed)
+ self.amount_e.textEdited.connect(reset_max)
+ self.fiat_send_e.textEdited.connect(reset_max)
+
+ self.invoices_label = QLabel(_('Invoices'))
+ self.invoice_list = InvoiceList(self)
+ self.toolbar, menu = self.invoice_list.create_toolbar_with_menu('')
+
+ add_input_actions_to_context_menu(self.payto_e, menu)
+ self.paytomany_menu = menu.addToggle(_("&Pay to many"), self.toggle_paytomany)
+ menu.addSeparator()
+ menu.addAction(_("Import invoices"), self.window.import_invoices)
+ menu.addAction(_("Export invoices"), self.window.export_invoices)
+
+ vbox0 = QVBoxLayout()
+ vbox0.addLayout(grid)
+ hbox = QHBoxLayout()
+ hbox.addLayout(vbox0)
+ hbox.addStretch(1)
+
+ vbox = QVBoxLayout(self)
+ vbox.addLayout(self.toolbar)
+ vbox.addLayout(hbox)
+ vbox.addStretch(1)
+ vbox.addWidget(self.invoices_label)
+ vbox.addWidget(self.invoice_list)
+ vbox.setStretchFactor(self.invoice_list, 1000)
+ self.searchable_list = self.invoice_list
+ self.invoice_list.update() # after parented and put into a layout, can update without flickering
+ run_hook('create_send_tab', grid)
+
+ self.resolve_done_signal.connect(self.on_resolve_done)
+ self.finalize_done_signal.connect(self.on_finalize_done)
+ self.payto_e.paymentIdentifierChanged.connect(self._handle_payment_identifier)
+
+ self.setTabOrder(self.send_button, self.invoice_list)
+
+ def on_amount_changed(self, text):
+ # FIXME: implement full valid amount check to enable/disable Pay button
+ pi = self.payto_e.payment_identifier
+ if not pi:
+ self.send_button.setEnabled(False)
+ return
+ pi_error = pi.is_error() if pi.is_valid() else False
+ is_spk_script = pi.type == PaymentIdentifierType.SPK and not pi.spk_is_address
+ valid_amount = is_spk_script or bool(self.amount_e.get_amount())
+ ready_to_finalize = not pi.need_resolve()
+ self.send_button.setEnabled(pi.is_valid() and not pi_error and valid_amount and ready_to_finalize)
+
+ def do_paste(self):
+ self.logger.debug('do_paste')
+ try:
+ self.payto_e.try_payment_identifier(self.app.clipboard().text())
+ except InvalidPaymentIdentifier as e:
+ self.show_error(_('Invalid payment identifier on clipboard'))
+
+ def set_payment_identifier(self, text):
+ self.logger.debug('set_payment_identifier')
+ try:
+ self.payto_e.try_payment_identifier(text)
+ except InvalidPaymentIdentifier as e:
+ self.show_error(_('Invalid payment identifier'))
+
+ def spend_max(self):
+ pi = self.payto_e.payment_identifier
+
+ if pi is None or pi.type == PaymentIdentifierType.UNKNOWN:
+ return
+ elif pi.type not in [PaymentIdentifierType.SPK, PaymentIdentifierType.MULTILINE,
+ PaymentIdentifierType.BIP21, PaymentIdentifierType.OPENALIAS]:
+ # clear the amount field once it is clear this PI is not eligible for '!'
+ self.amount_e.clear()
+ return
+
+ if pi.type == PaymentIdentifierType.BIP21:
+ assert 'amount' not in pi.bip21
+
+ if run_hook('abort_send', self):
+ return
+ outputs = pi.get_onchain_outputs('!')
+ if not outputs:
+ return
+ make_tx = lambda fee_policy, *, confirmed_only=False: self.wallet.make_unsigned_transaction(
+ fee_policy=fee_policy,
+ coins=self.window.get_coins(),
+ outputs=outputs,
+ is_sweep=False)
+ try:
+ try:
+ tx = make_tx(FeePolicy(self.config.FEE_POLICY))
+ except (NotEnoughFunds, NoDynamicFeeEstimates) as e:
+ # Check if we had enough funds excluding fees,
+ # if so, still provide opportunity to set lower fees.
+ tx = make_tx(FixedFeePolicy(0))
+ except NotEnoughFunds as e:
+ self.max_button.setChecked(False)
+ text = self.wallet.get_text_not_enough_funds_mentioning_frozen(for_amount='!')
+ self.show_error(text)
+ return
+
+ self.max_button.setChecked(True)
+ amount = tx.output_value()
+ __, x_fee_amount = run_hook('get_tx_extra_fee', self.wallet, tx) or (None, 0)
+ amount_after_all_fees = amount - x_fee_amount
+ self.amount_e.setAmount(amount_after_all_fees)
+ # show tooltip explaining max amount
+ mining_fee = tx.get_fee()
+ mining_fee_str = self.format_amount_and_units(mining_fee)
+ msg = _("Mining fee: {} (can be adjusted on next screen)").format(mining_fee_str)
+ if x_fee_amount:
+ twofactor_fee_str = self.format_amount_and_units(x_fee_amount)
+ msg += "\n" + _("2fa fee: {} (for the next batch of transactions)").format(twofactor_fee_str)
+ frozen_bal = self.wallet.get_frozen_balance_str()
+ if frozen_bal:
+ msg += "\n" + _("Some coins are frozen: {} (can be unfrozen in the Addresses or in the Coins tab)").format(frozen_bal)
+ QToolTip.showText(self.max_button.mapToGlobal(QPoint(0, 0)), msg)
+
+ # TODO: instead of passing outputs, use an invoice instead (like pay_lightning_invoice)
+ # so we have more context (we cannot rely on send_tab field contents or payment identifier
+ # as this method is called from other places as well).
+ def pay_onchain_dialog(
+ self,
+ outputs: List[PartialTxOutput],
+ *,
+ nonlocal_only=False,
+ external_keypairs: Mapping[bytes, bytes] = None,
+ get_coins: Callable[..., Sequence[PartialTxInput]] = None,
+ invoice: Optional[Invoice] = None
+ ) -> None:
+ # trustedcoin requires this
+ if run_hook('abort_send', self):
+ return
+
+ is_sweep = bool(external_keypairs)
+ # we call get_coins inside make_tx, so that inputs can be changed dynamically
+ if get_coins is None:
+ get_coins = self.window.get_coins
+
+ def make_tx(fee_policy, *, confirmed_only=False, base_tx=None):
+ coins = get_coins(nonlocal_only=nonlocal_only, confirmed_only=confirmed_only)
+ return self.wallet.make_unsigned_transaction(
+ fee_policy=fee_policy,
+ coins=coins,
+ outputs=outputs,
+ base_tx=base_tx,
+ is_sweep=is_sweep,
+ send_change_to_lightning=self.config.WALLET_SEND_CHANGE_TO_LIGHTNING,
+ merge_duplicate_outputs=self.config.WALLET_MERGE_DUPLICATE_OUTPUTS,
+ )
+ output_values = [x.value for x in outputs]
+ is_max = any(parse_max_spend(outval) for outval in output_values)
+ output_value = '!' if is_max else sum(output_values)
+
+ # To find batching candidates, we need to know our available UTXOs.
+ # Ideally should use same set of coins make_tx() will use.
+ # note: - prone to races: coins set might change due to new txs between now and make_tx() call
+ # - make_tx() might pass different params to get_coins()
+ # - to mitigate, we prefer to be more restrictive. hence confirmed_only=True
+ coins_conservative = get_coins(nonlocal_only=True, confirmed_only=True)
+ candidates = self.wallet.get_candidates_for_batching(outputs, coins=coins_conservative)
+
+ tx, is_preview, paid_with_swap = self.window.confirm_tx_dialog(
+ make_tx,
+ output_value,
+ payee_outputs=[o for o in outputs if not o.is_change],
+ batching_candidates=candidates,
+ )
+ if tx is None:
+ if paid_with_swap:
+ self.do_clear()
+ # user cancelled or paid with swap
+ return
+
+ if is_preview:
+ self.window.show_transaction(
+ tx,
+ external_keypairs=external_keypairs,
+ invoice=invoice,
+ show_sign_button=self.wallet.wallet_type != '2fa',
+ show_broadcast_button=self.wallet.wallet_type != '2fa',
+ )
+ return
+ self.save_pending_invoice()
+ def sign_done(success):
+ if success:
+ self.window.broadcast_or_show(tx, invoice=invoice)
+ self.window.sign_tx(
+ tx,
+ callback=sign_done,
+ external_keypairs=external_keypairs)
+
+ def do_clear(self):
+ self.logger.debug('do_clear')
+ self.lock_fields(lock_recipient=False, lock_amount=False, lock_max=True, lock_description=False)
+ self.max_button.setChecked(False)
+ self.payto_e.do_clear()
+ for w in [self.comment_e, self.comment_label]:
+ w.setVisible(False)
+ for w in [self.message_e, self.amount_e, self.fiat_send_e, self.comment_e]:
+ w.setText('')
+ w.setToolTip('')
+ for w in [self.save_button, self.send_button]:
+ w.setEnabled(False)
+ self.window.update_status()
+ self.paytomany_menu.setChecked(self.payto_e.multiline)
+ self.invoice_error.setText('')
+
+ run_hook('do_clear', self)
+
+ def prepare_for_send_tab_network_lookup(self):
+ for btn in [self.save_button, self.send_button, self.clear_button]:
+ btn.setEnabled(False)
+ self.spinner.setVisible(True)
+
+ def set_field_validated(self, w, *, validated: Optional[bool] = None):
+ if validated is not None:
+ w.setStyleSheet(ColorScheme.GREEN.as_stylesheet(True) if validated else ColorScheme.RED.as_stylesheet(True))
+
+ def lock_fields(
+ self, *,
+ lock_recipient: Optional[bool] = None,
+ lock_amount: Optional[bool] = None,
+ lock_max: Optional[bool] = None,
+ lock_description: Optional[bool] = None
+ ) -> None:
+ self.logger.debug(f'locking fields, r={lock_recipient}, a={lock_amount}, m={lock_max}, d={lock_description}')
+ if lock_recipient is not None:
+ self.payto_e.setFrozen(lock_recipient)
+ if lock_amount is not None:
+ self.amount_e.setFrozen(lock_amount)
+ if lock_max is not None:
+ self.max_button.setEnabled(not lock_max)
+ if lock_max is True:
+ self.max_button.setChecked(False)
+ if lock_description is not None:
+ self.message_e.setFrozen(lock_description)
+
+ def update_fields(self):
+ self.logger.debug('update_fields')
+ pi = self.payto_e.payment_identifier
+
+ self.clear_button.setEnabled(True)
+
+ if pi.is_multiline():
+ self.lock_fields(lock_recipient=False, lock_amount=True, lock_max=True, lock_description=False)
+ self.set_field_validated(self.payto_e, validated=pi.is_valid()) # TODO: validated used differently here than openalias
+ self.save_button.setEnabled(pi.is_valid())
+ self.send_button.setEnabled(pi.is_valid())
+ self.payto_e.setToolTip(pi.get_error() if not pi.is_valid() else '')
+ if pi.is_valid():
+ self.handle_multiline(pi.multiline_outputs)
+ return
+
+ if not pi.is_valid():
+ self.lock_fields(lock_recipient=False, lock_amount=False, lock_max=True, lock_description=False)
+ self.save_button.setEnabled(False)
+ self.send_button.setEnabled(False)
+ return
+
+ lock_recipient = pi.type in [PaymentIdentifierType.LNURL, PaymentIdentifierType.LNURLW,
+ PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR,
+ PaymentIdentifierType.OPENALIAS,
+ PaymentIdentifierType.BIP21, PaymentIdentifierType.BOLT11] and not pi.need_resolve()
+ lock_amount = pi.is_amount_locked()
+ lock_max = lock_amount or pi.type not in [PaymentIdentifierType.SPK, PaymentIdentifierType.BIP21]
+
+ self.lock_fields(lock_recipient=lock_recipient,
+ lock_amount=lock_amount,
+ lock_max=lock_max,
+ lock_description=False)
+ if lock_recipient:
+ fields = pi.get_fields_for_GUI()
+ if fields.recipient:
+ self.payto_e.setText(fields.recipient)
+ if fields.description:
+ self.message_e.setText(fields.description)
+ self.lock_fields(lock_description=True)
+ if fields.amount:
+ self.amount_e.setAmount(fields.amount)
+ for w in [self.comment_e, self.comment_label]:
+ w.setVisible(bool(fields.comment))
+ if fields.comment:
+ self.comment_e.setToolTip(_('Max comment length: {} characters').format(fields.comment))
+ self.set_field_validated(self.payto_e, validated=fields.validated)
+
+ # LNURLp amount range
+ if fields.amount_range:
+ amin, amax = fields.amount_range
+ self.amount_e.setToolTip(_('Amount must be between {} and {} sat.').format(amin, amax))
+ else:
+ self.amount_e.setToolTip('')
+
+ # resolve '!' in amount editor if it was set before PI
+ if not lock_max and self.amount_e.text() == '!':
+ self.spend_max()
+ elif lock_max and self.amount_e.text() == '!':
+ self.amount_e.clear()
+
+ pi_unusable = pi.is_error() or (not self.wallet.has_lightning() and not pi.is_onchain())
+ is_spk_script = pi.type == PaymentIdentifierType.SPK and not pi.spk_is_address
+
+ amount_valid = is_spk_script or bool(self.amount_e.get_amount())
+
+ self.send_button.setEnabled(not pi_unusable and amount_valid and not pi.has_expired())
+ self.save_button.setEnabled(not pi_unusable and not is_spk_script and not pi.has_expired() and \
+ pi.type not in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR])
+
+ self.invoice_error.setText(_('Expired') if pi.has_expired() else '')
+
+ def _handle_payment_identifier(self):
+ self.update_fields()
+
+ if not self.payto_e.payment_identifier.is_valid():
+ self.logger.debug(f'PI error: {self.payto_e.payment_identifier.error}')
+ return
+
+ if self.payto_e.payment_identifier.need_resolve():
+ self.prepare_for_send_tab_network_lookup()
+ self.payto_e.payment_identifier.resolve(on_finished=self.resolve_done_signal.emit)
+
+ def on_resolve_done(self, pi: 'PaymentIdentifier'):
+ # TODO: resolve can happen while typing, we don't want message dialogs to pop up
+ # currently we don't set error for emaillike recipients to avoid just that
+ self.logger.debug('payment identifier resolve done')
+ self.spinner.setVisible(False)
+ if pi.error:
+ self.show_error(pi.error)
+ self.do_clear()
+ return
+ if pi.type == PaymentIdentifierType.LNURLW:
+ assert pi.state == PaymentIdentifierState.LNURLW_FINALIZE, \
+ f"Detected LNURLW but not ready to finalize? {pi=}"
+ self.do_clear()
+ self.request_lnurl_withdraw_dialog(pi.lnurl_data)
+ return
+
+ # if openalias add openalias to contacts
+ if pi.type == PaymentIdentifierType.OPENALIAS:
+ key = pi.emaillike if pi.emaillike else pi.domainlike
+ pi.contacts[key] = ('openalias', pi.openalias_data.get('name'))
+
+ self.update_fields()
+
+ def get_message(self):
+ return self.message_e.text()
+
+ def read_invoice(self) -> Optional[Invoice]:
+ if self.check_payto_line_and_show_errors():
+ return
+
+ amount_sat = self.read_amount()
+ invoice = invoice_from_payment_identifier(
+ self.payto_e.payment_identifier, self.wallet, amount_sat, self.get_message())
+ if not invoice:
+ self.show_error('error getting invoice' + self.payto_e.payment_identifier.error)
+ return
+
+ if not self.wallet.has_lightning() and not invoice.can_be_paid_onchain():
+ self.show_error(_('Lightning is disabled'))
+ if self.wallet.get_invoice_status(invoice) == PR_PAID:
+ # fixme: this is only for lightning
+ self.show_error(_('Invoice already paid'))
+ return
+ #if not invoice.is_lightning():
+ # if self.check_onchain_outputs_and_show_errors(outputs):
+ # return
+ return invoice
+
+ def do_save_invoice(self):
+ self.pending_invoice = self.read_invoice()
+ if not self.pending_invoice:
+ return
+ self.save_pending_invoice()
+
+ def save_pending_invoice(self):
+ if not self.pending_invoice:
+ return
+ self.do_clear()
+ self.wallet.save_invoice(self.pending_invoice)
+ self.invoice_list.update()
+ self.pending_invoice = None
+
+ def get_amount(self) -> int:
+ # must not be None
+ return self.amount_e.get_amount() or 0
+
+ def on_finalize_done(self, pi: PaymentIdentifier):
+ self.spinner.setVisible(False)
+ self.update_fields()
+ if pi.error:
+ self.show_error(pi.error)
+ return
+ invoice = pi.bolt11
+ self.pending_invoice = invoice
+ self.logger.debug(f'after finalize invoice: {invoice!r}')
+ self.do_pay_invoice(invoice)
+
+ def do_pay_or_get_invoice(self):
+ pi = self.payto_e.payment_identifier
+ if pi.need_finalize():
+ self.prepare_for_send_tab_network_lookup()
+ pi.finalize(amount_sat=self.get_amount(), comment=self.comment_e.text(),
+ on_finished=self.finalize_done_signal.emit)
+ return
+ self.pending_invoice = self.read_invoice()
+ if not self.pending_invoice:
+ return
+ self.do_pay_invoice(self.pending_invoice)
+
+ def pay_multiple_invoices(self, invoices):
+ outputs = []
+ for invoice in invoices:
+ outputs += invoice.outputs
+ self.pay_onchain_dialog(outputs)
+
+ def do_edit_invoice(self, invoice: 'Invoice'): # FIXME broken
+ assert not bool(invoice.get_amount_sat())
+ text = invoice.lightning_invoice if invoice.is_lightning() else invoice.get_address()
+ self.set_payment_identifier(text)
+ self.amount_e.setFocus()
+ # disable save button, because it would create a new invoice
+ self.save_button.setEnabled(False)
+
+ def do_pay_invoice(self, invoice: 'Invoice'):
+ if not bool(invoice.get_amount_sat()):
+ pi = self.payto_e.payment_identifier
+ if pi.type == PaymentIdentifierType.SPK and not pi.spk_is_address:
+ pass
+ else:
+ self.show_error(_('No amount'))
+ return
+ if invoice.is_lightning():
+ self.pay_lightning_invoice(invoice)
+ else:
+ self.pay_onchain_dialog(invoice.outputs, invoice=invoice)
+
+ def read_amount(self) -> Union[int, str]:
+ amount = '!' if self.max_button.isChecked() else self.get_amount()
+ return amount
+
+ def check_onchain_outputs_and_show_errors(self, outputs: List[PartialTxOutput]) -> bool:
+ """Returns whether there are errors with outputs.
+ Also shows error dialog to user if so.
+ """
+ if not outputs:
+ self.show_error(_('No outputs'))
+ return True
+
+ for o in outputs:
+ if o.scriptpubkey is None:
+ self.show_error(_('Bitcoin Address is None'))
+ return True
+ if o.value is None:
+ self.show_error(_('Invalid Amount'))
+ return True
+
+ return False # no errors
+
+ def check_payto_line_and_show_errors(self) -> bool:
+ """Returns whether there are errors.
+ Also shows error dialog to user if so.
+ """
+ error = self.payto_e.payment_identifier.get_error()
+ if error:
+ if not self.payto_e.payment_identifier.is_multiline():
+ err = error
+ self.show_warning(
+ _("Failed to parse 'Pay to' line") + ":\n" +
+ f"{err.line_content[:40]}...\n\n"
+ f"{err.exc!r}")
+ else:
+ self.show_warning(
+ _("Invalid Lines found:") + "\n\n" + error)
+ #'\n'.join([_("Line #") +
+ # f"{err.idx+1}: {err.line_content[:40]}... ({err.exc!r})"
+ # for err in errors]))
+ return True
+
+ warning = self.payto_e.payment_identifier.warning
+ if warning:
+ warning += '\n' + _('Do you wish to continue?')
+ if not self.question(warning):
+ return True
+
+ if self.payto_e.payment_identifier.has_expired():
+ self.show_error(_('Payment request has expired'))
+ return True
+
+ return False # no errors
+
+ def pay_lightning_invoice(self, invoice: Invoice):
+ amount_sat = invoice.get_amount_sat()
+ if amount_sat is None:
+ raise Exception("missing amount for LN invoice")
+ # note: lnworker might be None if LN is disabled,
+ # in which case we should still offer the user to pay onchain.
+ lnworker = self.wallet.lnworker
+ if lnworker is None or not lnworker.can_pay_invoice(invoice):
+ coins = self.window.get_coins(nonlocal_only=True)
+ can_pay_with_new_channel = False
+ can_pay_with_swap = False
+ can_rebalance = False
+ if lnworker:
+ can_pay_with_new_channel = lnworker.suggest_funding_amount(amount_sat, coins=coins)
+ can_pay_with_swap = lnworker.suggest_swap_to_send(amount_sat, coins=coins)
+ rebalance_suggestion = lnworker.suggest_rebalance_to_send(amount_sat)
+ can_rebalance = bool(rebalance_suggestion) and self.window.num_tasks() == 0
+ choices = [] # type: List[ChoiceItem]
+ if can_rebalance:
+ msg = ''.join([
+ _('Rebalance existing channels'), '\n',
+ _('Move funds between your channels in order to increase your sending capacity.')
+ ])
+ choices.append(ChoiceItem(key='rebalance', label=msg))
+ if can_pay_with_new_channel:
+ msg = ''.join([
+ _('Open a new channel'), '\n',
+ _('You will be able to pay once the channel is open.')
+ ])
+ choices.append(ChoiceItem(key='new_channel', label=msg))
+ if can_pay_with_swap:
+ msg = ''.join([
+ _('Swap onchain funds for lightning funds'), '\n',
+ _('You will be able to pay once the swap is confirmed.')
+ ])
+ choices.append(ChoiceItem(key='swap', label=msg))
+ msg = _('You cannot pay that invoice using Lightning.')
+ if lnworker and lnworker.channels:
+ num_sats_can_send = int(lnworker.num_sats_can_send())
+ msg += '\n' + _('Your channels can send {}.').format(self.format_amount(num_sats_can_send) + ' ' + self.base_unit())
+ if not choices:
+ self.window.show_error(msg)
+ return
+ r = self.window.query_choice(msg, choices)
+ if r is not None:
+ self.save_pending_invoice()
+ if r == 'rebalance':
+ chan1, chan2, delta = rebalance_suggestion
+ self.window.rebalance_dialog(chan1, chan2, amount_sat=delta)
+ elif r == 'new_channel':
+ amount_sat, min_amount_sat = can_pay_with_new_channel
+ self.window.new_channel_dialog(amount_sat=amount_sat, min_amount_sat=min_amount_sat)
+ elif r == 'swap':
+ chan, swap_recv_amount_sat = can_pay_with_swap
+ self.window.run_swap_dialog(is_reverse=False, recv_amount_sat_or_max=swap_recv_amount_sat, channels=[chan])
+ elif r == 'onchain':
+ self.pay_onchain_dialog(invoice.get_outputs(), nonlocal_only=True, invoice=invoice)
+ return
+
+ assert lnworker is not None
+ # FIXME this is currently lying to user as we truncate to satoshis
+ amount_msat = invoice.get_amount_msat()
+ label = QLabel(
+ _("This will send {} to the recipient").format(self.format_amount_and_units(Decimal(amount_msat)/1000)))
+
+ dialog = WindowModalDialog(self, _("Pay lightning invoice?"))
+ dialog.setMinimumWidth(400)
+ vbox = QVBoxLayout()
+ dialog.setLayout(vbox)
+ vbox.addWidget(label)
+ vbox.addStretch(1)
+
+ lnfee_hlabel = HelpLabel.from_configvar(self.config.cv.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS)
+ lnfee_hlabel.setText(_('Max routing fee') + ' :')
+ lnfee_map = [500, 1_000, 3_000, 5_000, 10_000, 20_000, 30_000, 50_000]
+ def lnfee_update_vlabel(fee_val: int):
+ lnfee_vlabel.setText(_("{}% of payment").format(f"{fee_val / 10 ** 4:.2f}"))
+ def lnfee_slider_moved():
+ pos = lnfee_slider.sliderPosition()
+ fee_val = lnfee_map[pos]
+ lnfee_update_vlabel(fee_val)
+ self.config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS = fee_val
+ lnfee_slider = QSlider(Qt.Orientation.Horizontal)
+ lnfee_slider.setRange(0, len(lnfee_map)-1)
+ lnfee_slider.setTracking(True)
+ try:
+ lnfee_spos = lnfee_map.index(self.config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS)
+ except ValueError:
+ lnfee_spos = 0
+ lnfee_slider.setSliderPosition(lnfee_spos)
+ lnfee_vlabel = QLabel("")
+ lnfee_update_vlabel(self.config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS)
+ lnfee_slider.valueChanged.connect(lnfee_slider_moved)
+ grid = QGridLayout()
+ grid.setSpacing(8)
+ grid.setColumnStretch(3, 1) # Make the last column stretch
+ grid.addWidget(lnfee_hlabel, 0, 0)
+ grid.addWidget(lnfee_vlabel, 0, 1)
+ grid.addWidget(lnfee_slider, 1, 1)
+ vbox.addLayout(grid)
+
+ pay_button = OkButton(dialog, _("Pay"))
+ cancel_button = CancelButton(dialog)
+ vbox.addLayout(Buttons(cancel_button, pay_button))
+ if not dialog.exec():
+ return
+ self.save_pending_invoice()
+ coro = lnworker.pay_invoice(invoice, amount_msat=amount_msat)
+ self.window.run_coroutine_from_thread(coro, _('Sending payment'))
+
+ def broadcast_transaction(self, tx: Transaction, *, invoice: Invoice = None):
+ if hasattr(tx, 'swap_payment_hash'):
+ sm = self.wallet.lnworker.swap_manager
+ swap = sm.get_swap(tx.swap_payment_hash)
+ with sm.create_transport() as transport:
+ coro = sm.wait_for_htlcs_and_broadcast(
+ transport=transport, swap=swap, invoice=tx.swap_invoice, tx=tx)
+ try:
+ funding_txid = self.window.run_coroutine_dialog(coro, _('Awaiting lightning payment...'))
+ except UserCancelled:
+ sm.cancel_normal_swap(swap)
+ return
+ self.window.on_swap_result(funding_txid, is_reverse=False)
+
+ def broadcast_thread():
+ # non-GUI thread
+ if invoice and invoice.has_expired():
+ return False, _("Invoice has expired")
+ try:
+ self.network.run_from_another_thread(self.network.broadcast_transaction(tx))
+ except TxBroadcastError as e:
+ return False, e.get_message_for_gui()
+ except BestEffortRequestFailed as e:
+ return False, repr(e)
+ # success
+ return True, tx.txid()
+
+ # Capture current TL window; override might be removed on return
+ parent = self.window.top_level_window(lambda win: isinstance(win, MessageBoxMixin))
+
+ # FIXME: move to backend and let Abstract_Wallet set broadcasting state, not gui
+ self.wallet.set_broadcasting(tx, broadcasting_status=PR_BROADCASTING)
+
+ def broadcast_done(result):
+ # GUI thread
+ if result:
+ success, msg = result
+ if success:
+ parent.show_message(_('Payment sent.') + '\n' + msg)
+ self.invoice_list.update()
+ self.wallet.set_broadcasting(tx, broadcasting_status=PR_BROADCAST)
+ else:
+ msg = msg or ''
+ parent.show_error(msg)
+ self.wallet.set_broadcasting(tx, broadcasting_status=None)
+
+ WaitingDialog(self, _('Broadcasting transaction...'),
+ broadcast_thread, broadcast_done, self.window.on_error)
+
+ def toggle_paytomany(self):
+ self.payto_e.toggle_paytomany()
+ if self.payto_e.is_paytomany():
+ message = '\n'.join([
+ _('Enter a list of outputs in the \'Pay to\' field.'),
+ _('One output per line.'),
+ _('Format: address, amount'),
+ _('You may load a CSV file using the file icon.')
+ ])
+ self.window.show_tooltip_after_delay(message)
+ self.payto_label.setAlignment(Qt.AlignmentFlag.AlignTop)
+ self.payto_label.setText(_('Pay to many'))
+ else:
+ self.payto_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
+ self.payto_label.setText(_('Pay to'))
+
+ def payto_contacts(self, labels):
+ paytos = [self.window.get_contact_payto(label) for label in labels]
+ self.window.show_send_tab()
+ self.do_clear()
+ if len(paytos) == 1:
+ self.logger.debug('payto_e setText 1')
+ self.payto_e.setText(paytos[0])
+ self.amount_e.setFocus()
+ else:
+ self.payto_e.setFocus()
+ text = "\n".join([payto + ", 0" for payto in paytos])
+ self.logger.debug('payto_e setText n')
+ self.payto_e.setText(text)
+ self.payto_e.setFocus()
+
+ def handle_multiline(self, outputs):
+ total = 0
+ for output in outputs:
+ if parse_max_spend(output.value):
+ self.max_button.setChecked(True) # TODO: remove and let spend_max set this?
+ self.spend_max()
+ return
+ else:
+ total += output.value
+ self.amount_e.setAmount(total if outputs else None)
+
+ def request_lnurl_withdraw_dialog(self, lnurl_data: LNURL3Data):
+ if not self.wallet.has_lightning():
+ self.show_error(
+ _("Cannot request lightning withdrawal, wallet has no lightning channels.")
+ )
+ return
+
+ dialog = WindowModalDialog(self, _("Lightning Withdrawal"))
+ dialog.setMinimumWidth(400)
+
+ vbox = QVBoxLayout()
+ dialog.setLayout(vbox)
+ grid = QGridLayout()
+ grid.setSpacing(8)
+ grid.setColumnStretch(3, 1) # Make the last column stretch
+
+ row = 0
+
+ # provider url
+ domain_label = QLabel(_("Provider") + ":")
+ domain_text = WWLabel(urllib.parse.urlparse(lnurl_data.callback_url).netloc)
+ grid.addWidget(domain_label, row, 0)
+ grid.addWidget(domain_text, row, 1, 1, 3)
+ row += 1
+
+ if lnurl_data.default_description:
+ desc_label = QLabel(_("Description") + ":")
+ desc_text = WWLabel(lnurl_data.default_description)
+ grid.addWidget(desc_label, row, 0)
+ grid.addWidget(desc_text, row, 1, 1, 3)
+ row += 1
+
+ min_amount = max(lnurl_data.min_withdrawable_sat, 1)
+ max_amount = min(
+ lnurl_data.max_withdrawable_sat,
+ int(self.wallet.lnworker.num_sats_can_receive())
+ )
+ min_text = self.format_amount_and_units(lnurl_data.min_withdrawable_sat)
+ if min_amount > int(self.wallet.lnworker.num_sats_can_receive()):
+ self.show_error("".join([
+ _("Too little incoming liquidity to satisfy this withdrawal request."), "\n\n",
+ _("Can receive: {}").format(
+ self.format_amount_and_units(self.wallet.lnworker.num_sats_can_receive()),
+ ), "\n",
+ _("Minimum withdrawal amount: {}").format(min_text), "\n\n",
+ _("Do a submarine swap in the 'Channels' tab to get more incoming liquidity.")
+ ]))
+ return
+
+ is_fixed_amount = lnurl_data.min_withdrawable_sat == lnurl_data.max_withdrawable_sat
+
+ # Range information (only for non-fixed amounts)
+ if not is_fixed_amount:
+ range_label_text = QLabel(_("Range") + ":")
+ range_value = QLabel("{} - {}".format(
+ min_text,
+ self.format_amount_and_units(lnurl_data.max_withdrawable_sat)
+ ))
+ grid.addWidget(range_label_text, row, 0)
+ grid.addWidget(range_value, row, 1, 1, 2)
+ row += 1
+
+ # Amount section
+ amount_label = QLabel(_("Amount") + ":")
+ amount_edit = BTCAmountEdit(self.window.get_decimal_point, max_amount=max_amount)
+ amount_edit.setAmount(max_amount)
+ grid.addWidget(amount_label, row, 0)
+ grid.addWidget(amount_edit, row, 1)
+
+ if is_fixed_amount:
+ # Fixed amount, just show the amount
+ amount_edit.setDisabled(True)
+ else:
+ # Range, show max button
+ max_button = EnterButton(_("Max"), lambda: amount_edit.setAmount(max_amount))
+ btn_width = 10 * char_width_in_lineedit()
+ max_button.setFixedWidth(btn_width)
+ grid.addWidget(max_button, row, 2)
+
+ row += 1
+
+ # Warning for insufficient liquidity
+ if lnurl_data.max_withdrawable_sat > int(self.wallet.lnworker.num_sats_can_receive()):
+ warning_text = WWLabel(
+ _("The maximum withdrawable amount is larger than what your channels can receive. "
+ "You may need to do a submarine swap to increase your incoming liquidity.")
+ )
+ warning_text.setStyleSheet("color: orange;")
+ grid.addWidget(warning_text, row, 0, 1, 4)
+ row += 1
+
+ vbox.addLayout(grid)
+
+ # Buttons
+ request_button = OkButton(dialog, _("Request Withdrawal"))
+ cancel_button = CancelButton(dialog)
+ vbox.addLayout(Buttons(cancel_button, request_button))
+
+ # Show dialog and handle result
+ if dialog.exec():
+ if is_fixed_amount:
+ amount_sat = lnurl_data.max_withdrawable_sat
+ else:
+ amount_sat = amount_edit.get_amount()
+ if not amount_sat or not (min_amount <= int(amount_sat) <= max_amount):
+ self.show_error(_("Enter a valid amount. You entered: {}").format(amount_sat))
+ return
+ else:
+ return
+
+ try:
+ key = self.wallet.create_request(
+ amount_sat=amount_sat,
+ message=lnurl_data.default_description,
+ exp_delay=120,
+ address=None,
+ )
+ req = self.wallet.get_request(key)
+ info = self.wallet.lnworker.get_payment_info(req.payment_hash, direction=RECEIVED)
+ _lnaddr, b11_invoice = self.wallet.lnworker.get_bolt11_invoice(
+ payment_info=info,
+ message=req.get_message(),
+ fallback_address=None,
+ )
+ except Exception as e:
+ self.logger.exception('')
+ self.show_error(
+ f"{_('Failed to create payment request for withdrawal')}: {str(e)}"
+ )
+ return
+
+ coro = request_lnurl_withdraw_callback(
+ callback_url=lnurl_data.callback_url,
+ k1=lnurl_data.k1,
+ bolt_11=b11_invoice
+ )
+ try:
+ self.window.run_coroutine_dialog(coro, _("Requesting lightning withdrawal..."))
+ except LNURLError as e:
+ self.show_error(f"{_('Failed to request withdrawal')}:\n{str(e)}")
+ except UserCancelled:
+ pass
diff --git a/electrum/gui/qt/settings_dialog.py b/electrum/gui/qt/settings_dialog.py
new file mode 100644
index 000000000000..81a7eb4f5d22
--- /dev/null
+++ b/electrum/gui/qt/settings_dialog.py
@@ -0,0 +1,429 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2012 thomasv@gitorious
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import ast
+import sys
+from typing import TYPE_CHECKING, Dict
+
+from PyQt6.QtCore import Qt
+from PyQt6.QtWidgets import (QComboBox, QTabWidget, QDialog, QSpinBox, QCheckBox, QLabel,
+ QVBoxLayout, QGridLayout, QLineEdit, QWidget, QHBoxLayout)
+
+from electrum.i18n import _, get_gui_lang_names
+from electrum import util
+from electrum.util import base_units_list, event_listener
+
+from electrum.gui.common_qt.util import QtEventListener
+from electrum.gui import messages
+
+from .util import ColorScheme, HelpLabel, Buttons, CloseButton
+
+if TYPE_CHECKING:
+ from electrum.simple_config import SimpleConfig, ConfigVarWithConfig
+ from .main_window import ElectrumWindow
+
+
+def checkbox_from_configvar(cv: 'ConfigVarWithConfig') -> QCheckBox:
+ short_desc = cv.get_short_desc()
+ assert short_desc is not None, f"short_desc missing for {cv}"
+ cb = QCheckBox(short_desc)
+ if (long_desc := cv.get_long_desc()) is not None:
+ cb.setToolTip(messages.to_rtf(long_desc))
+ return cb
+
+
+class SettingsDialog(QDialog, QtEventListener):
+
+ def __init__(self, window: 'ElectrumWindow', config: 'SimpleConfig'):
+ QDialog.__init__(self)
+ self.setWindowTitle(_('Preferences'))
+ self.setMinimumWidth(500)
+ self.config = config
+ self.network = window.network
+ self.app = window.app
+ self.need_restart = False
+ self.fx = window.fx
+ self.wallet = window.wallet
+
+ self.register_callbacks()
+ self.app.alias_received_signal.connect(self.set_alias_color)
+
+ vbox = QVBoxLayout()
+ tabs = QTabWidget()
+
+ # language
+ lang_label = HelpLabel.from_configvar(self.config.cv.LOCALIZATION_LANGUAGE)
+ lang_combo = QComboBox()
+ _languages = get_gui_lang_names()
+ lang_combo.addItems(list(_languages.values()))
+ lang_keys = list(_languages.keys())
+ lang_cur_setting = self.config.LOCALIZATION_LANGUAGE
+ try:
+ index = lang_keys.index(lang_cur_setting)
+ except ValueError: # not in list
+ index = 0
+ lang_combo.setCurrentIndex(index)
+ if not self.config.cv.LOCALIZATION_LANGUAGE.is_modifiable():
+ for w in [lang_combo, lang_label]: w.setEnabled(False)
+
+ def on_lang(x):
+ lang_request = list(_languages.keys())[lang_combo.currentIndex()]
+ if lang_request != self.config.LOCALIZATION_LANGUAGE:
+ self.config.LOCALIZATION_LANGUAGE = lang_request
+ self.need_restart = True
+ lang_combo.currentIndexChanged.connect(on_lang)
+
+ nz_label = HelpLabel.from_configvar(self.config.cv.BTC_AMOUNTS_FORCE_NZEROS_AFTER_DECIMAL_POINT)
+ nz = QSpinBox()
+ nz.setMinimum(0)
+ nz.setMaximum(self.config.BTC_AMOUNTS_DECIMAL_POINT)
+ nz.setValue(self.config.num_zeros)
+ if not self.config.cv.BTC_AMOUNTS_FORCE_NZEROS_AFTER_DECIMAL_POINT.is_modifiable():
+ for w in [nz, nz_label]: w.setEnabled(False)
+
+ def on_nz():
+ value = nz.value()
+ if self.config.num_zeros != value:
+ self.config.num_zeros = value
+ self.config.BTC_AMOUNTS_FORCE_NZEROS_AFTER_DECIMAL_POINT = value
+ self.app.refresh_tabs_signal.emit()
+ self.app.update_status_signal.emit()
+ nz.valueChanged.connect(on_nz)
+
+ # lightning
+ trampoline_cb = checkbox_from_configvar(self.config.cv.LIGHTNING_USE_GOSSIP)
+ trampoline_cb.setChecked(not self.config.LIGHTNING_USE_GOSSIP)
+
+ def on_trampoline_checked(_x):
+ use_trampoline = trampoline_cb.isChecked()
+ if not use_trampoline:
+ if not window.question('\n'.join([
+ _("Are you sure you want to disable trampoline?"),
+ _("Without this option, Electrum will need to sync with the Lightning network on every start."),
+ _("This may impact the reliability of your payments."),
+ ]), parent=self):
+ trampoline_cb.setCheckState(Qt.CheckState.Checked)
+ return
+ self.config.LIGHTNING_USE_GOSSIP = not use_trampoline
+ if self.network:
+ if not use_trampoline:
+ self.network.start_gossip()
+ else:
+ self.network.run_from_another_thread(
+ self.network.stop_gossip())
+ util.trigger_callback('ln_gossip_sync_progress')
+ # FIXME: update all wallet windows
+ util.trigger_callback('channels_updated', self.wallet)
+ trampoline_cb.stateChanged.connect(on_trampoline_checked)
+
+
+ alias_label = HelpLabel.from_configvar(self.config.cv.OPENALIAS_ID)
+ alias = self.config.OPENALIAS_ID
+ self.alias_e = QLineEdit(alias)
+ self.set_alias_color()
+ self.alias_e.editingFinished.connect(self.on_alias_edit)
+
+
+ msat_cb = checkbox_from_configvar(self.config.cv.BTC_AMOUNTS_PREC_POST_SAT)
+ msat_cb.setChecked(self.config.BTC_AMOUNTS_PREC_POST_SAT > 0)
+
+ def on_msat_checked(_x):
+ prec = 3 if msat_cb.isChecked() else 0
+ if self.config.amt_precision_post_satoshi != prec:
+ self.config.amt_precision_post_satoshi = prec
+ self.config.BTC_AMOUNTS_PREC_POST_SAT = prec
+ self.app.refresh_tabs_signal.emit()
+
+ msat_cb.stateChanged.connect(on_msat_checked)
+
+ # units
+ units = base_units_list
+ msg = (_('Base unit of your wallet.')
+ + '\n1 BTC = 1000 mBTC. 1 mBTC = 1000 bits. 1 bit = 100 sat.\n'
+ + _('This setting affects the Send tab, and all balance related fields.'))
+ unit_label = HelpLabel(_('Base unit') + ':', msg)
+ unit_combo = QComboBox()
+ unit_combo.addItems(units)
+ unit_combo.setCurrentIndex(units.index(self.config.get_base_unit()))
+
+ def on_unit(x, nz):
+ unit_result = units[unit_combo.currentIndex()]
+ if self.config.get_base_unit() == unit_result:
+ return
+ self.config.set_base_unit(unit_result)
+ nz.setMaximum(self.config.BTC_AMOUNTS_DECIMAL_POINT)
+ self.app.refresh_tabs_signal.emit()
+ self.app.update_status_signal.emit()
+ self.app.refresh_amount_edits_signal.emit()
+ unit_combo.currentIndexChanged.connect(lambda x: on_unit(x, nz))
+
+ thousandsep_cb = checkbox_from_configvar(self.config.cv.BTC_AMOUNTS_ADD_THOUSANDS_SEP)
+ thousandsep_cb.setChecked(self.config.BTC_AMOUNTS_ADD_THOUSANDS_SEP)
+
+ def on_set_thousandsep(_x):
+ checked = thousandsep_cb.isChecked()
+ if self.config.amt_add_thousands_sep != checked:
+ self.config.amt_add_thousands_sep = checked
+ self.config.BTC_AMOUNTS_ADD_THOUSANDS_SEP = checked
+ self.app.refresh_tabs_signal.emit()
+ thousandsep_cb.stateChanged.connect(on_set_thousandsep)
+
+ qr_combo = QComboBox()
+ qr_combo.addItem("Default", "default")
+ qr_label = HelpLabel.from_configvar(self.config.cv.VIDEO_DEVICE_PATH)
+ from .qrreader import find_system_cameras
+ system_cameras = find_system_cameras()
+ for cam_desc, cam_path in system_cameras.items():
+ qr_combo.addItem(cam_desc, cam_path)
+ index = qr_combo.findData(self.config.VIDEO_DEVICE_PATH)
+ qr_combo.setCurrentIndex(index)
+
+ def on_video_device(x):
+ self.config.VIDEO_DEVICE_PATH = qr_combo.itemData(x)
+ qr_combo.currentIndexChanged.connect(on_video_device)
+
+ colortheme_combo = QComboBox()
+ colortheme_combo.addItem(_('Light'), 'default')
+ colortheme_combo.addItem(_('Dark'), 'dark')
+ index = colortheme_combo.findData(self.config.GUI_QT_COLOR_THEME)
+ colortheme_combo.setCurrentIndex(index)
+ colortheme_label = QLabel(self.config.cv.GUI_QT_COLOR_THEME.get_short_desc() + ':')
+
+ def on_colortheme(x):
+ self.config.GUI_QT_COLOR_THEME = colortheme_combo.itemData(x)
+ self.need_restart = True
+ colortheme_combo.currentIndexChanged.connect(on_colortheme)
+
+ updatecheck_cb = checkbox_from_configvar(self.config.cv.AUTOMATIC_CENTRALIZED_UPDATE_CHECKS)
+ updatecheck_cb.setChecked(self.config.AUTOMATIC_CENTRALIZED_UPDATE_CHECKS)
+
+ def on_set_updatecheck(_x):
+ self.config.AUTOMATIC_CENTRALIZED_UPDATE_CHECKS = updatecheck_cb.isChecked()
+ updatecheck_cb.stateChanged.connect(on_set_updatecheck)
+
+ filelogging_cb = checkbox_from_configvar(self.config.cv.WRITE_LOGS_TO_DISK)
+ filelogging_cb.setChecked(self.config.WRITE_LOGS_TO_DISK)
+
+ def on_set_filelogging(_x):
+ self.config.WRITE_LOGS_TO_DISK = filelogging_cb.isChecked()
+ self.need_restart = True
+ filelogging_cb.stateChanged.connect(on_set_filelogging)
+
+ screenshot_protection_cb = checkbox_from_configvar(
+ self.config.cv.GUI_QT_SCREENSHOT_PROTECTION
+ )
+ screenshot_protection_cb.setChecked(self.config.GUI_QT_SCREENSHOT_PROTECTION)
+ if sys.platform not in ['windows', 'win32']:
+ screenshot_protection_cb.setChecked(False)
+ screenshot_protection_cb.setDisabled(True)
+ screenshot_protection_cb.setToolTip(_("This option is only available on Windows"))
+
+ def on_set_screenshot_protection(_x):
+ self.config.GUI_QT_SCREENSHOT_PROTECTION = screenshot_protection_cb.isChecked()
+ self.need_restart = True
+ screenshot_protection_cb.stateChanged.connect(on_set_screenshot_protection)
+
+ block_explorers = sorted(util.block_explorer_info().keys())
+ BLOCK_EX_CUSTOM_ITEM = _("Custom URL")
+ if BLOCK_EX_CUSTOM_ITEM in block_explorers: # malicious translation?
+ block_explorers.remove(BLOCK_EX_CUSTOM_ITEM)
+ block_explorers.append(BLOCK_EX_CUSTOM_ITEM)
+ block_ex_label = HelpLabel.from_configvar(self.config.cv.BLOCK_EXPLORER)
+ block_ex_combo = QComboBox()
+ block_ex_custom_e = QLineEdit(str(self.config.BLOCK_EXPLORER_CUSTOM or ''))
+ block_ex_combo.addItems(block_explorers)
+ block_ex_combo.setCurrentIndex(
+ block_ex_combo.findText(util.block_explorer(self.config) or BLOCK_EX_CUSTOM_ITEM))
+
+ def showhide_block_ex_custom_e():
+ block_ex_custom_e.setVisible(block_ex_combo.currentText() == BLOCK_EX_CUSTOM_ITEM)
+ showhide_block_ex_custom_e()
+
+ def on_be_combo(x):
+ if block_ex_combo.currentText() == BLOCK_EX_CUSTOM_ITEM:
+ on_be_edit()
+ else:
+ be_result = block_explorers[block_ex_combo.currentIndex()]
+ self.config.BLOCK_EXPLORER_CUSTOM = None
+ self.config.BLOCK_EXPLORER = be_result
+ showhide_block_ex_custom_e()
+
+ block_ex_combo.currentIndexChanged.connect(on_be_combo)
+
+ def on_be_edit():
+ val = block_ex_custom_e.text()
+ try:
+ val = ast.literal_eval(val) # to also accept tuples
+ except Exception:
+ pass
+ self.config.BLOCK_EXPLORER_CUSTOM = val
+
+ block_ex_custom_e.editingFinished.connect(on_be_edit)
+ block_ex_hbox = QHBoxLayout()
+ block_ex_hbox.setContentsMargins(0, 0, 0, 0)
+ block_ex_hbox.setSpacing(0)
+ block_ex_hbox.addWidget(block_ex_combo)
+ block_ex_hbox.addWidget(block_ex_custom_e)
+ block_ex_hbox_w = QWidget()
+ block_ex_hbox_w.setLayout(block_ex_hbox)
+
+ # Fiat Currency
+ self.history_rates_cb = checkbox_from_configvar(self.config.cv.FX_HISTORY_RATES)
+ ccy_combo = QComboBox()
+ ex_combo = QComboBox()
+
+ def update_currencies():
+ if not self.fx:
+ return
+ h = self.config.FX_HISTORY_RATES
+ currencies = sorted(self.fx.get_currencies(h))
+ ccy_combo.clear()
+ ccy_combo.addItems([_('None')] + currencies)
+ if self.fx.is_enabled():
+ ccy_combo.setCurrentIndex(ccy_combo.findText(self.fx.get_currency()))
+
+ def update_exchanges():
+ if not self.fx: return
+ b = self.fx.is_enabled()
+ ex_combo.setEnabled(b)
+ if b:
+ h = self.config.FX_HISTORY_RATES
+ c = self.fx.get_currency()
+ exchanges = self.fx.get_exchanges_by_ccy(c, h)
+ else:
+ exchanges = self.fx.get_exchanges_by_ccy('USD', False)
+ ex_combo.blockSignals(True)
+ ex_combo.clear()
+ ex_combo.addItems(sorted(exchanges))
+ ex_combo.setCurrentIndex(ex_combo.findText(self.fx.config_exchange()))
+ ex_combo.blockSignals(False)
+
+ def on_currency(hh):
+ if not self.fx: return
+ b = bool(ccy_combo.currentIndex())
+ ccy = str(ccy_combo.currentText()) if b else None
+ self.fx.set_enabled(b)
+ if b and ccy != self.fx.ccy:
+ self.fx.set_currency(ccy)
+ update_exchanges()
+ self.app.update_fiat_signal.emit()
+
+ def on_exchange(idx):
+ exchange = str(ex_combo.currentText())
+ if self.fx and self.fx.is_enabled() and exchange and exchange != self.fx.exchange.name():
+ self.fx.set_exchange(exchange)
+ self.app.update_fiat_signal.emit()
+
+ def on_history_rates(_x):
+ self.config.FX_HISTORY_RATES = self.history_rates_cb.isChecked()
+ if not self.fx:
+ return
+ update_exchanges()
+ window.app.update_fiat_signal.emit()
+
+ update_currencies()
+ update_exchanges()
+ ccy_combo.currentIndexChanged.connect(on_currency)
+ self.history_rates_cb.setChecked(self.config.FX_HISTORY_RATES)
+ self.history_rates_cb.stateChanged.connect(on_history_rates)
+ ex_combo.currentIndexChanged.connect(on_exchange)
+
+ gui_widgets = []
+ gui_widgets.append((lang_label, lang_combo))
+ gui_widgets.append((colortheme_label, colortheme_combo))
+ gui_widgets.append((block_ex_label, block_ex_hbox_w))
+ units_widgets = []
+ units_widgets.append((unit_label, unit_combo))
+ units_widgets.append((nz_label, nz))
+ units_widgets.append((msat_cb, None))
+ units_widgets.append((thousandsep_cb, None))
+ lightning_widgets = []
+ lightning_widgets.append((trampoline_cb, None))
+ fiat_widgets = []
+ fiat_widgets.append((QLabel(_('Fiat currency')), ccy_combo))
+ fiat_widgets.append((QLabel(_('Source')), ex_combo))
+ fiat_widgets.append((self.history_rates_cb, None))
+ misc_widgets = []
+ misc_widgets.append((updatecheck_cb, None))
+ misc_widgets.append((filelogging_cb, None))
+ misc_widgets.append((screenshot_protection_cb, None))
+ misc_widgets.append((alias_label, self.alias_e))
+ misc_widgets.append((qr_label, qr_combo))
+
+ tabs_info = [
+ (gui_widgets, _('Appearance')),
+ (units_widgets, _('Units')),
+ (fiat_widgets, _('Fiat')),
+ (lightning_widgets, _('Lightning')),
+ (misc_widgets, _('Misc')),
+ ]
+ for widgets, name in tabs_info:
+ tab = QWidget()
+ tab_vbox = QVBoxLayout(tab)
+ grid = QGridLayout()
+ for a,b in widgets:
+ i = grid.rowCount()
+ if b:
+ if a:
+ grid.addWidget(a, i, 0)
+ grid.addWidget(b, i, 1)
+ else:
+ grid.addWidget(a, i, 0, 1, 2)
+ tab_vbox.addLayout(grid)
+ tab_vbox.addStretch(1)
+ tabs.addTab(tab, name)
+
+ vbox.addWidget(tabs)
+ vbox.addStretch(1)
+ vbox.addLayout(Buttons(CloseButton(self)))
+ self.setLayout(vbox)
+
+ @event_listener
+ def on_event_alias_received(self):
+ self.app.alias_received_signal.emit()
+
+ def set_alias_color(self):
+ if not self.config.OPENALIAS_ID:
+ self.alias_e.setStyleSheet("")
+ return
+ if self.wallet.contacts.alias_info:
+ self.alias_e.setStyleSheet(ColorScheme.GREEN.as_stylesheet(True))
+ else:
+ self.alias_e.setStyleSheet(ColorScheme.RED.as_stylesheet(True))
+
+ def on_alias_edit(self):
+ self.alias_e.setStyleSheet("")
+ alias = str(self.alias_e.text())
+ self.config.OPENALIAS_ID = alias
+ if alias:
+ self.wallet.contacts.fetch_openalias(self.config)
+
+ def closeEvent(self, event):
+ self.unregister_callbacks()
+ try:
+ self.app.alias_received_signal.disconnect(self.set_alias_color)
+ except TypeError:
+ pass # 'method' object is not connected
+ event.accept()
diff --git a/electrum/gui/qt/stylesheet_patcher.py b/electrum/gui/qt/stylesheet_patcher.py
new file mode 100644
index 000000000000..88045e466cd0
--- /dev/null
+++ b/electrum/gui/qt/stylesheet_patcher.py
@@ -0,0 +1,69 @@
+"""This is used to patch the QApplication style sheet.
+It reads the current stylesheet, appends our modifications and sets the new stylesheet.
+"""
+
+import sys
+
+from PyQt6 import QtWidgets
+
+
+CUSTOM_PATCH_FOR_DARK_THEME = '''
+/* PayToEdit text was being clipped */
+QAbstractScrollArea {
+ padding: 0px;
+}
+/* In History tab, labels while edited were being clipped (Windows) */
+QAbstractItemView QLineEdit {
+ padding: 0px;
+ show-decoration-selected: 1;
+}
+/* Checked item in dropdowns have way too much height...
+ see #6281 and https://github.com/ColinDuquesnoy/QDarkStyleSheet/issues/200
+ */
+QComboBox::item:checked {
+ font-weight: bold;
+ max-height: 30px;
+}
+'''
+
+CUSTOM_PATCH_FOR_DEFAULT_THEME_MACOS = '''
+/* On macOS, main window status bar icons have ugly frame (see #6300) */
+StatusBarButton {
+ background-color: transparent;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ margin: 0px;
+ padding: 2px;
+}
+StatusBarButton:checked {
+ background-color: transparent;
+ border: 1px solid #1464A0;
+}
+StatusBarButton:checked:disabled {
+ border: 1px solid #14506E;
+}
+StatusBarButton:pressed {
+ margin: 1px;
+ background-color: transparent;
+ border: 1px solid #1464A0;
+}
+StatusBarButton:disabled {
+ border: none;
+}
+StatusBarButton:hover {
+ border: 1px solid #148CD2;
+}
+'''
+
+
+def patch_qt_stylesheet(use_dark_theme: bool) -> None:
+ custom_patch = ""
+ if use_dark_theme:
+ custom_patch = CUSTOM_PATCH_FOR_DARK_THEME
+ else: # default theme (typically light)
+ if sys.platform == 'darwin':
+ custom_patch = CUSTOM_PATCH_FOR_DEFAULT_THEME_MACOS
+
+ app = QtWidgets.QApplication.instance()
+ style_sheet = app.styleSheet() + custom_patch
+ app.setStyleSheet(style_sheet)
diff --git a/electrum/gui/qt/swap_dialog.py b/electrum/gui/qt/swap_dialog.py
new file mode 100644
index 000000000000..afc37b7e0b2d
--- /dev/null
+++ b/electrum/gui/qt/swap_dialog.py
@@ -0,0 +1,582 @@
+import enum
+from typing import TYPE_CHECKING, Optional, Union, Tuple, Sequence, Callable
+
+from PyQt6.QtCore import pyqtSignal, Qt, QTimer
+from PyQt6.QtGui import QIcon, QPixmap, QColor
+from PyQt6.QtWidgets import QLabel, QVBoxLayout, QGridLayout, QPushButton
+from PyQt6.QtWidgets import QTreeWidget, QTreeWidgetItem, QHeaderView
+
+from electrum_aionostr.util import from_nip19
+
+from electrum.i18n import _
+from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, UserCancelled, trigger_callback
+from electrum.bitcoin import DummyAddress
+from electrum.transaction import PartialTxOutput, PartialTransaction, PartialTxInput
+from electrum.fee_policy import FeePolicy
+from electrum.submarine_swaps import NostrTransport
+
+from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
+from electrum.gui import messages
+
+from . import util
+from .util import (WindowModalDialog, Buttons, OkButton, CancelButton,
+ EnterButton, ColorScheme, WWLabel, read_QIcon, IconLabel, char_width_in_lineedit,
+ pubkey_to_q_icon)
+from .amountedit import BTCAmountEdit
+from .fee_slider import FeeSlider, FeeComboBox
+from .my_treeview import create_toolbar_with_menu, MyTreeView
+
+if TYPE_CHECKING:
+ from .main_window import ElectrumWindow
+ from electrum.submarine_swaps import SwapServerTransport, SwapOffer
+ from electrum.lnchannel import Channel
+ from electrum.simple_config import SimpleConfig
+
+CANNOT_RECEIVE_WARNING = _(
+"""The requested amount is higher than what you can receive in your currently open channels.
+If you continue, your funds will be locked until the remote server can find a path to pay you.
+If the swap cannot be performed after 24h, you will be refunded.
+Do you want to continue?"""
+)
+
+
+ROLE_NPUB = Qt.ItemDataRole.UserRole + 1000
+
+class InvalidSwapParameters(Exception): pass
+
+
+class SwapProvidersButton(QPushButton):
+
+ def __init__(
+ self,
+ transport_getter: Callable[[], Optional['SwapServerTransport']],
+ config: 'SimpleConfig',
+ main_window: 'ElectrumWindow',
+ ):
+ """parent must have a transport() method"""
+ QPushButton.__init__(self)
+ self.config = config
+ self.transport_getter = transport_getter
+ self.main_window = main_window
+ self.clicked.connect(self.choose_swap_server)
+ self.fetching = False
+ self.update()
+
+ def update(self):
+ if self.fetching:
+ self.setEnabled(False)
+ self.setText(_("Fetching..."))
+ self.setVisible(True)
+ return
+
+ transport = self.transport_getter()
+ if not isinstance(transport, NostrTransport):
+ # HTTPTransport or no Network, not showing server selection button
+ self.setEnabled(False)
+ self.setVisible(False)
+ return
+ self.setEnabled(True)
+ self.setVisible(True)
+ offer_count = len(transport.get_recent_offers())
+ button_text = f' {offer_count} ' + (_('swap providers') if offer_count != 1 else _('swap provider'))
+ self.setText(button_text)
+ # update icon
+ if self.config.SWAPSERVER_NPUB:
+ pubkey = from_nip19(self.config.SWAPSERVER_NPUB)['object'].hex()
+ self.setIcon(pubkey_to_q_icon(pubkey))
+
+ def choose_swap_server(self) -> None:
+ transport = self.transport_getter()
+ assert isinstance(transport, NostrTransport), transport
+ self.main_window.choose_swapserver_dialog(transport) # type: ignore
+ self.update()
+ trigger_callback('swap_provider_changed')
+
+
+class SwapDialog(WindowModalDialog, QtEventListener):
+ def __init__(
+ self,
+ window: 'ElectrumWindow',
+ transport: 'SwapServerTransport',
+ *,
+ is_reverse: Optional[bool] = None,
+ recv_amount_sat_or_max: Optional[Union[int, str]] = None, # sat or '!'
+ channels: Optional[Sequence['Channel']] = None,
+ get_coins: Optional[Callable[..., Sequence['PartialTxInput']]] = None,
+ ):
+ WindowModalDialog.__init__(self, window, _('Submarine Swap'))
+ self.window = window
+ self.config = window.config
+ self.lnworker = self.window.wallet.lnworker
+ self.swap_manager = self.lnworker.swap_manager
+ self.network = window.network
+ self.channels = channels
+ self.is_reverse = is_reverse if is_reverse is not None else True
+ self.get_coins = get_coins
+
+ vbox = QVBoxLayout(self)
+
+ self.transport = transport
+ self.server_button = SwapProvidersButton(lambda: self.transport, self.config, self.window)
+ self.description_label = WWLabel(self.get_description())
+ self.send_amount_e = BTCAmountEdit(self.window.get_decimal_point)
+ self.recv_amount_e = BTCAmountEdit(self.window.get_decimal_point)
+ self.max_button = EnterButton(_("Max"), self.spend_max)
+ btn_width = 10 * char_width_in_lineedit()
+ self.max_button.setFixedWidth(btn_width)
+ self.max_button.setCheckable(True)
+ self.toggle_button = QPushButton(' \U000021c4 ') # whitespace to force larger min width
+ self.toggle_button.setEnabled(is_reverse is None)
+ # send_follows is used to know whether the send amount field / receive
+ # amount field should be adjusted after the fee slider was moved
+ self.send_follows = False
+ self.send_amount_e.follows = False
+ self.recv_amount_e.follows = False
+ self.toggle_button.clicked.connect(self.toggle_direction)
+ # textChanged is triggered for both user and automatic action
+ self.send_amount_e.textChanged.connect(self.on_send_edited)
+ self.recv_amount_e.textChanged.connect(self.on_recv_edited)
+ # textEdited is triggered only for user editing of the fields
+ self.send_amount_e.textEdited.connect(self.uncheck_max)
+ self.recv_amount_e.textEdited.connect(self.uncheck_max)
+
+ self.fee_policy = FeePolicy(self.config.FEE_POLICY)
+ self.fee_slider = FeeSlider(parent=self, network=self.network, fee_policy=self.fee_policy, callback=self.fee_slider_callback)
+ self.fee_combo = FeeComboBox(self.fee_slider)
+ self.fee_target_label = QLabel()
+ self._set_fee_slider_visibility(is_visible=not self.is_reverse)
+
+ self.swap_limits_label = QLabel()
+ self.fee_label = QLabel()
+ self.server_fee_label = QLabel()
+ self.last_server_mining_fee_sat = None
+ h = QGridLayout()
+ h.addWidget(self.description_label, 0, 0, 1, 3)
+ h.addWidget(self.toggle_button, 0, 3)
+ self.send_label = IconLabel(text=_('You send')+':')
+ self.recv_label = IconLabel(text=_('You receive')+':')
+ h.addWidget(self.send_label, 1, 0)
+ h.addWidget(self.send_amount_e, 1, 1)
+ h.addWidget(self.max_button, 1, 2)
+ h.addWidget(self.recv_label, 2, 0)
+ h.addWidget(self.recv_amount_e, 2, 1)
+ h.addWidget(QLabel(_('Swap limits')+':'), 4, 0)
+ h.addWidget(self.swap_limits_label, 4, 1, 1, 2)
+ h.addWidget(QLabel(_('Server fee')+':'), 5, 0)
+ h.addWidget(self.server_fee_label, 5, 1, 1, 2)
+ h.addWidget(QLabel(_('Mining fee')+':'), 6, 0)
+ h.addWidget(self.fee_label, 6, 1, 1, 2)
+ h.addWidget(self.fee_slider, 7, 1)
+ h.addWidget(self.fee_combo, 7, 2)
+ h.addWidget(self.fee_target_label, 7, 0)
+ h.addWidget(QLabel(''), 8, 0)
+ vbox.addLayout(h)
+ vbox.addStretch()
+ self.ok_button = OkButton(self)
+ self.ok_button.setDefault(True)
+ self.ok_button.setEnabled(False)
+ buttons = Buttons(CancelButton(self), self.ok_button)
+ vbox.addLayout(buttons)
+ buttons.insertWidget(0, self.server_button)
+ if recv_amount_sat_or_max:
+ assert isinstance(recv_amount_sat_or_max, (int, str)), f"invalid {type(recv_amount_sat_or_max)=}"
+ self.init_recv_amount(recv_amount_sat_or_max)
+ self.update()
+ self.needs_tx_update = True
+
+ self.timer = QTimer(self)
+ self.timer.setInterval(500)
+ self.timer.setSingleShot(False)
+ self.timer.timeout.connect(self.timer_actions)
+ self.timer.start()
+
+ self.finished.connect(self.on_finished)
+
+ self.fee_slider.update()
+ self.register_callbacks()
+
+ def closeEvent(self, event):
+ self.unregister_callbacks()
+ event.accept()
+
+ def on_finished(self, *args):
+ self.timer.stop()
+
+ @qt_event_listener
+ def on_event_fee_histogram(self, *args):
+ self.update_send_receive()
+
+ @qt_event_listener
+ def on_event_fee(self, *args):
+ self.update_send_receive()
+
+ @qt_event_listener
+ def on_event_swap_offers_changed(self, recent_offers: Sequence['SwapOffer']):
+ self.server_button.update()
+ if not self.ok_button.isEnabled():
+ # only update the dialog with the new offer if the user hasn't entered an amount yet.
+ # if the user has already entered an amount we prefer the swap to fail due to outdated
+ # fees than the possibility of a swap happening with fees the user hasn't seen
+ # due to an update happening just before the user initiated the swap
+ self.update()
+
+ @qt_event_listener
+ def on_event_swap_provider_changed(self):
+ self.update()
+ self.update_send_receive()
+
+ def timer_actions(self):
+ if self.needs_tx_update:
+ self.update_tx()
+ self.update_ok_button()
+ self.needs_tx_update = False
+
+ def init_recv_amount(self, recv_amount_sat):
+ if recv_amount_sat == '!':
+ self.max_button.setChecked(True)
+ self.spend_max()
+ else:
+ recv_amount_sat = max(recv_amount_sat, self.swap_manager.get_min_amount())
+ self.recv_amount_e.setAmount(recv_amount_sat)
+
+ def fee_slider_callback(self, fee_rate):
+ self.config.FEE_POLICY = self.fee_policy.get_descriptor()
+ if not self.is_reverse:
+ self.fee_target_label.setText(self.fee_policy.get_target_text())
+ self.update_send_receive()
+ self.update()
+
+ def _set_fee_slider_visibility(self, *, is_visible: bool):
+ if is_visible:
+ self.fee_slider.setEnabled(True)
+ self.fee_combo.setEnabled(True)
+ self.fee_target_label.setText(self.fee_policy.get_target_text())
+ else:
+ self.fee_slider.setEnabled(False)
+ self.fee_combo.setEnabled(False)
+ # show the eta of the swap claim
+ self.fee_target_label.setText(FeePolicy(self.config.FEE_POLICY_SWAPS).get_target_text())
+
+ def toggle_direction(self):
+ self.is_reverse = not self.is_reverse
+ self._set_fee_slider_visibility(is_visible=not self.is_reverse)
+ self.send_amount_e.setAmount(None)
+ self.recv_amount_e.setAmount(None)
+ self.max_button.setChecked(False)
+ self.update()
+
+ def spend_max(self):
+ if self.max_button.isChecked():
+ if self.is_reverse:
+ self._spend_max_reverse_swap()
+ else:
+ # spend_max_forward_swap will be called in update_tx
+ pass
+ else:
+ self.send_amount_e.setAmount(None)
+ self.needs_tx_update = True
+
+ def uncheck_max(self):
+ self.max_button.setChecked(False)
+ self.update()
+
+ def _spend_max_forward_swap(self, tx: Optional[PartialTransaction]) -> None:
+ if tx:
+ amount = tx.output_value_for_address(DummyAddress.SWAP)
+ self.send_amount_e.setAmount(amount)
+ else:
+ self.send_amount_e.setAmount(None)
+ self.max_button.setChecked(False)
+
+ def _spend_max_reverse_swap(self) -> None:
+ amount = min(self.lnworker.num_sats_can_send(), self.swap_manager.get_provider_max_forward_amount())
+ amount = int(amount) # round down msats
+ self.send_amount_e.setAmount(amount)
+
+ def on_send_edited(self):
+ if self.send_amount_e.follows:
+ return
+ self.send_amount_e.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet())
+ send_amount = self.send_amount_e.get_amount()
+ recv_amount = self.swap_manager.get_recv_amount(send_amount, is_reverse=self.is_reverse)
+ if self.is_reverse and send_amount and send_amount > self.lnworker.num_sats_can_send():
+ # cannot send this much on lightning
+ recv_amount = None
+ if (not self.is_reverse) and recv_amount and recv_amount > self.lnworker.num_sats_can_receive():
+ # cannot receive this much on lightning
+ recv_amount = None
+ self.recv_amount_e.follows = True
+ self.recv_amount_e.setAmount(recv_amount)
+ self.recv_amount_e.setStyleSheet(ColorScheme.BLUE.as_stylesheet())
+ self.recv_amount_e.follows = False
+ self.send_follows = False
+ self.needs_tx_update = True
+
+ def on_recv_edited(self):
+ if self.recv_amount_e.follows:
+ return
+ self.recv_amount_e.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet())
+ recv_amount = self.recv_amount_e.get_amount()
+ send_amount = self.swap_manager.get_send_amount(recv_amount, is_reverse=self.is_reverse)
+ if self.is_reverse and send_amount and send_amount > self.lnworker.num_sats_can_send():
+ send_amount = None
+ self.send_amount_e.follows = True
+ self.send_amount_e.setAmount(send_amount)
+ self.send_amount_e.setStyleSheet(ColorScheme.BLUE.as_stylesheet())
+ self.send_amount_e.follows = False
+ self.send_follows = True
+ self.needs_tx_update = True
+
+ def update_send_receive(self):
+ self.on_recv_edited() if self.send_follows else self.on_send_edited()
+
+ def update(self):
+ sm = self.swap_manager
+ w_base_unit = self.window.base_unit()
+ send_icon = read_QIcon("lightning.png" if self.is_reverse else "bitcoin.png")
+ self.send_label.setIcon(send_icon)
+ recv_icon = read_QIcon("lightning.png" if not self.is_reverse else "bitcoin.png")
+ self.recv_label.setIcon(recv_icon)
+ self.description_label.setText(self.get_description())
+ self.description_label.repaint() # macOS hack for #6269
+ min_swap_limit, max_swap_limit = self.get_client_swap_limits_sat()
+ if max_swap_limit == 0:
+ swap_name = _("reverse") if self.is_reverse else _("forward")
+ swap_limit_str = _("No {} swap possible with this provider").format(swap_name)
+ else:
+ swap_limit_str = (f"{self.window.format_amount(min_swap_limit)} - "
+ f"{self.window.format_amount(max_swap_limit)} {w_base_unit}")
+ self.swap_limits_label.setText(swap_limit_str)
+ self.swap_limits_label.repaint() # macOS hack for #6269
+ self.last_server_mining_fee_sat = sm.mining_fee
+ server_fee_str = '%.2f'%sm.percentage + '% + ' + self.window.format_amount(sm.mining_fee) + ' ' + w_base_unit
+ self.server_fee_label.setText(server_fee_str)
+ self.server_fee_label.repaint() # macOS hack for #6269
+ self.needs_tx_update = True
+
+ def get_client_swap_limits_sat(self) -> Tuple[int, int]:
+ """Returns the (min, max) client swap limits in sat."""
+ sm = self.swap_manager
+
+ if self.is_reverse:
+ lower_limit = sm.get_min_amount()
+ upper_limit = sm.client_max_amount_reverse_swap() or 0
+ else:
+ lower_limit = sm.get_send_amount(sm.get_min_amount(), is_reverse=False) or sm.get_min_amount()
+ upper_limit = sm.client_max_amount_forward_swap() or 0
+
+ if lower_limit > upper_limit:
+ # if the max possible amount is below the lower limit no swap is possible
+ lower_limit, upper_limit = 0, 0
+ return lower_limit, upper_limit
+
+ def update_fee(self, tx: Optional[PartialTransaction]) -> None:
+ """Updates self.fee_label. No other side-effects."""
+ if self.is_reverse:
+ sm = self.swap_manager
+ fee = sm.get_fee_for_txbatcher()
+ else:
+ fee = tx.get_fee() if tx else None
+ fee_text = self.window.format_amount(fee) + ' ' + self.window.base_unit() if fee else _("no input")
+ self.fee_label.setText(fee_text)
+ self.fee_label.repaint() # macOS hack for #6269
+
+ def run(self, transport: 'SwapServerTransport') -> bool:
+ """Can raise InvalidSwapParameters."""
+ if not self.exec():
+ return False
+ if self.is_reverse:
+ lightning_amount = self.send_amount_e.get_amount()
+ onchain_amount = self.recv_amount_e.get_amount()
+ if lightning_amount is None or onchain_amount is None:
+ return False
+ sm = self.swap_manager
+ coro = sm.reverse_swap(
+ transport=transport,
+ lightning_amount_sat=lightning_amount,
+ expected_onchain_amount_sat=onchain_amount + self.swap_manager.get_fee_for_txbatcher(),
+ prepayment_sat=2 * self.last_server_mining_fee_sat,
+ )
+ try:
+ # we must not leave the context, so we use run_couroutine_dialog
+ funding_txid = self.window.run_coroutine_dialog(coro, _('Initiating swap...'))
+ except Exception as e:
+ self.window.show_error(f"Reverse swap failed: {str(e)}")
+ return False
+ self.window.on_swap_result(funding_txid, is_reverse=True)
+ return True
+ else:
+ lightning_amount = self.recv_amount_e.get_amount()
+ onchain_amount = self.send_amount_e.get_amount()
+ if lightning_amount is None or onchain_amount is None:
+ return False
+ if lightning_amount > self.lnworker.num_sats_can_receive():
+ if not self.window.question(CANNOT_RECEIVE_WARNING):
+ return False
+ self.window.protect(self.do_normal_swap, (transport, lightning_amount, onchain_amount))
+ return True
+
+ def update_tx(self) -> None:
+ if self.is_reverse:
+ self.update_fee(None)
+ return
+ is_max = self.max_button.isChecked()
+ if is_max:
+ tx = self._create_tx_safe('!')
+ self._spend_max_forward_swap(tx)
+ else:
+ onchain_amount = self.send_amount_e.get_amount()
+ tx = self._create_tx_safe(onchain_amount)
+ self.update_fee(tx)
+
+ def _create_tx(self, onchain_amount: Union[int, str, None]) -> PartialTransaction:
+ assert not self.is_reverse
+ if onchain_amount is None:
+ raise InvalidSwapParameters("onchain_amount is None")
+ coins = self.get_coins() if self.get_coins else self.window.get_coins()
+ if onchain_amount == '!':
+ max_amount = sum(c.value_sats() for c in coins)
+ max_swap_amount = self.swap_manager.client_max_amount_forward_swap()
+ if max_swap_amount is None:
+ raise InvalidSwapParameters("swap_manager.client_max_amount_forward_swap() is None")
+ if max_amount > max_swap_amount:
+ onchain_amount = max_swap_amount
+ outputs = [PartialTxOutput.from_address_and_value(DummyAddress.SWAP, onchain_amount)]
+ try:
+ tx = self.window.wallet.make_unsigned_transaction(
+ fee_policy=self.fee_policy,
+ coins=coins,
+ outputs=outputs,
+ send_change_to_lightning=False,
+ )
+ except (NotEnoughFunds, NoDynamicFeeEstimates) as e:
+ raise InvalidSwapParameters(str(e)) from e
+ return tx
+
+ def _create_tx_safe(self, onchain_amount: Union[int, str, None]) -> Optional[PartialTransaction]:
+ try:
+ return self._create_tx(onchain_amount=onchain_amount)
+ except InvalidSwapParameters:
+ return None
+
+ def update_ok_button(self):
+ """Updates self.ok_button. No other side-effects."""
+ send_amount = self.send_amount_e.get_amount()
+ recv_amount = self.recv_amount_e.get_amount()
+ self.ok_button.setEnabled(bool(send_amount) and bool(recv_amount))
+
+ async def _do_normal_swap(self, transport, lightning_amount, onchain_amount, password):
+ dummy_tx = self._create_tx(onchain_amount)
+ assert dummy_tx
+ sm = self.swap_manager
+ swap, invoice = await sm.request_normal_swap(
+ transport=transport,
+ lightning_amount_sat=lightning_amount,
+ expected_onchain_amount_sat=onchain_amount,
+ channels=self.channels,
+ )
+ self._current_swap = swap
+ tx = sm.create_funding_tx(swap, dummy_tx, password=password)
+ txid = await sm.wait_for_htlcs_and_broadcast(transport=transport, swap=swap, invoice=invoice, tx=tx)
+ return txid
+
+ def do_normal_swap(self, transport, lightning_amount, onchain_amount, password):
+ self._current_swap = None
+ coro = self._do_normal_swap(transport, lightning_amount, onchain_amount, password)
+ try:
+ funding_txid = self.window.run_coroutine_dialog(coro, _('Awaiting swap payment...'))
+ except UserCancelled:
+ self.swap_manager.cancel_normal_swap(self._current_swap)
+ self.window.show_message(_('Swap cancelled'))
+ return
+ except Exception as e:
+ self.window.show_error(str(e))
+ return
+ self.window.on_swap_result(funding_txid, is_reverse=False)
+
+ def get_description(self):
+ onchain_funds = "onchain"
+ lightning_funds = "lightning"
+
+ return "Send {fromType}, receive {toType}.\nThis will increase your lightning {capacityType} capacity.\n".format(
+ fromType=lightning_funds if self.is_reverse else onchain_funds,
+ toType=onchain_funds if self.is_reverse else lightning_funds,
+ capacityType="receiving" if self.is_reverse else "sending",
+ )
+
+
+class SwapServerDialog(WindowModalDialog, QtEventListener):
+
+ class Columns(MyTreeView.BaseColumnsEnum):
+ PUBKEY = enum.auto()
+ FEE = enum.auto()
+ MAX_FORWARD = enum.auto()
+ MAX_REVERSE = enum.auto()
+ LAST_SEEN = enum.auto()
+
+ headers = {
+ Columns.PUBKEY: _("Pubkey"),
+ Columns.FEE: _("Fee"),
+ Columns.MAX_FORWARD: _('Max Forward'),
+ Columns.MAX_REVERSE: _('Max Reverse'),
+ Columns.LAST_SEEN: _("Last seen"),
+ }
+
+ def __init__(self, window: 'ElectrumWindow', servers: Sequence['SwapOffer']):
+ WindowModalDialog.__init__(self, window, _('Choose Swap Provider'))
+ self.window = window
+ self.config = window.config
+ msg = '\n'.join([
+ _("Please choose a provider from this list."),
+ _("Note that fees and liquidity may be updated frequently.")
+ ])
+ self.servers_list = QTreeWidget()
+ col_names = [self.headers[col_idx] for col_idx in sorted(self.headers.keys())]
+ self.servers_list.setHeaderLabels(col_names)
+ self.servers_list.header().setStretchLastSection(False)
+ for col_idx in range(len(self.Columns)):
+ sm = QHeaderView.ResizeMode.Stretch if col_idx == self.Columns.PUBKEY else QHeaderView.ResizeMode.ResizeToContents
+ self.servers_list.header().setSectionResizeMode(col_idx, sm)
+ self.update_servers_list(servers)
+ vbox = QVBoxLayout()
+ self.setLayout(vbox)
+ vbox.addWidget(WWLabel(msg))
+ vbox.addWidget(self.servers_list, stretch=1)
+ vbox.addSpacing(10)
+ self.ok_button = OkButton(self)
+ vbox.addLayout(Buttons(CancelButton(self), self.ok_button))
+ self.setMinimumWidth(650)
+ self.register_callbacks()
+
+ def run(self):
+ if self.exec() != 1:
+ return None
+ if item := self.servers_list.currentItem():
+ return item.data(self.Columns.PUBKEY, ROLE_NPUB)
+ return None
+
+ def closeEvent(self, event):
+ self.unregister_callbacks()
+ event.accept()
+
+ @qt_event_listener
+ def on_event_swap_offers_changed(self, recent_offers: Sequence['SwapOffer']):
+ self.update_servers_list(recent_offers)
+
+ def update_servers_list(self, servers: Sequence['SwapOffer']):
+ self.servers_list.clear()
+ from electrum.util import age
+ items = []
+ for x in servers:
+ labels = [""] * len(self.Columns)
+ labels[self.Columns.PUBKEY] = x.server_pubkey
+ labels[self.Columns.FEE] = f"{x.pairs.percentage}% + {x.pairs.mining_fee} sats"
+ labels[self.Columns.MAX_FORWARD] = self.window.format_amount(x.pairs.max_forward) + ' ' + self.window.base_unit()
+ labels[self.Columns.MAX_REVERSE] = self.window.format_amount(x.pairs.max_reverse) + ' ' + self.window.base_unit()
+ labels[self.Columns.LAST_SEEN] = age(x.timestamp)
+ item = QTreeWidgetItem(labels)
+ item.setData(self.Columns.PUBKEY, ROLE_NPUB, x.server_npub)
+ item.setIcon(self.Columns.PUBKEY, pubkey_to_q_icon(x.server_pubkey))
+ items.append(item)
+ self.servers_list.insertTopLevelItems(0, items)
+
diff --git a/electrum/gui/qt/transaction_dialog.py b/electrum/gui/qt/transaction_dialog.py
new file mode 100644
index 000000000000..4f8345df4d0e
--- /dev/null
+++ b/electrum/gui/qt/transaction_dialog.py
@@ -0,0 +1,1131 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2012 thomasv@gitorious
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import asyncio
+import concurrent.futures
+import copy
+import datetime
+import time
+from typing import TYPE_CHECKING, Optional, List, Union, Mapping, Callable
+from functools import partial
+from decimal import Decimal
+
+from PyQt6.QtCore import QSize, Qt, QUrl, QPoint, pyqtSignal
+from PyQt6.QtGui import QTextCharFormat, QBrush, QFont, QPixmap, QTextCursor, QAction
+from PyQt6.QtWidgets import (QDialog, QLabel, QPushButton, QHBoxLayout, QVBoxLayout, QWidget,
+ QToolButton, QMenu, QTextBrowser,
+ QSizePolicy)
+import qrcode
+from qrcode import exceptions
+
+from electrum import bitcoin
+
+from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, DummyAddress
+from electrum.i18n import _
+from electrum.plugin import run_hook
+from electrum.transaction import SerializationError, Transaction, PartialTransaction, TxOutpoint, TxinDataFetchProgress
+from electrum.logging import get_logger
+from electrum.util import (ShortID, get_asyncio_loop, UI_UNIT_NAME_TXSIZE_VBYTES, delta_time_str,
+ UserCancelled)
+from electrum.network import Network
+from electrum.wallet import TxSighashRiskLevel, TxSighashDanger
+
+from .util import (MessageBoxMixin, read_QIcon, Buttons, icon_path,
+ MONOSPACE_FONT, ColorScheme, ButtonsLineEdit, ShowQRLineEdit, text_dialog,
+ char_width_in_lineedit, TRANSACTION_FILE_EXTENSION_FILTER_SEPARATE,
+ TRANSACTION_FILE_EXTENSION_FILTER_ONLY_COMPLETE_TX,
+ TRANSACTION_FILE_EXTENSION_FILTER_ONLY_PARTIAL_TX,
+ getSaveFileName, ColorSchemeItem,
+ get_icon_qrcode, VLine, WaitingDialog)
+from .rate_limiter import rate_limited
+from .my_treeview import create_toolbar_with_menu, QMenuWithConfig
+
+if TYPE_CHECKING:
+ from .main_window import ElectrumWindow
+ from electrum.wallet import Abstract_Wallet
+ from electrum.invoices import Invoice
+
+
+_logger = get_logger(__name__)
+dialogs = [] # Otherwise python randomly garbage collects the dialogs...
+
+
+class TxSizeLabel(QLabel):
+ def setAmount(self, byte_size):
+ text = ""
+ if byte_size:
+ text = f"x {byte_size} {UI_UNIT_NAME_TXSIZE_VBYTES} ="
+ self.setText(text)
+
+
+class TxFiatLabel(QLabel):
+ def setAmount(self, fiat_fee):
+ self.setText(('≈ %s' % fiat_fee) if fiat_fee else '')
+
+
+class QTextBrowserWithDefaultSize(QTextBrowser):
+ def __init__(self, width: int = 0, height: int = 0):
+ self._width = width
+ self._height = height
+ QTextBrowser.__init__(self)
+ self.setLineWrapMode(QTextBrowser.LineWrapMode.NoWrap)
+
+ def sizeHint(self):
+ return QSize(self._width, self._height)
+
+
+class TxInOutWidget(QWidget):
+ def __init__(self, main_window: 'ElectrumWindow', wallet: 'Abstract_Wallet'):
+ QWidget.__init__(self)
+
+ self.wallet = wallet
+ self.main_window = main_window
+ self.tx = None # type: Optional[Transaction]
+ self.inputs_header = QLabel()
+ self.inputs_textedit = QTextBrowserWithDefaultSize(750, 100)
+ self.inputs_textedit.setOpenLinks(False) # disable automatic link opening
+ self.inputs_textedit.anchorClicked.connect(self._open_internal_link) # send links to our handler
+ self.inputs_textedit.setTextInteractionFlags(
+ self.inputs_textedit.textInteractionFlags() | Qt.TextInteractionFlag.LinksAccessibleByMouse | Qt.TextInteractionFlag.LinksAccessibleByKeyboard)
+ self.inputs_textedit.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
+ self.inputs_textedit.customContextMenuRequested.connect(self.on_context_menu_for_inputs)
+
+ self.sighash_label = QLabel()
+ self.sighash_label.setStyleSheet('font-weight: bold')
+ self.sighash_danger = TxSighashDanger()
+ self.inputs_warning_icon = QLabel()
+ pixmap = QPixmap(icon_path("warning"))
+ pixmap_size = round(2 * char_width_in_lineedit())
+ pixmap = pixmap.scaled(pixmap_size, pixmap_size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
+ self.inputs_warning_icon.setPixmap(pixmap)
+ self.inputs_warning_icon.setVisible(False)
+
+ self.inheader_hbox = QHBoxLayout()
+ self.inheader_hbox.setContentsMargins(0, 0, 0, 0)
+ self.inheader_hbox.addWidget(self.inputs_header)
+ self.inheader_hbox.addStretch(2)
+ self.inheader_hbox.addWidget(self.sighash_label)
+ self.inheader_hbox.addWidget(self.inputs_warning_icon)
+
+ self.txo_color_recv = TxOutputColoring(
+ legend=_("Wallet Address"), color=ColorScheme.GREEN, tooltip=_("Wallet receiving address"))
+ self.txo_color_change = TxOutputColoring(
+ legend=_("Change Address"), color=ColorScheme.YELLOW, tooltip=_("Wallet change address"))
+ self.txo_color_accounting = TxOutputColoring(
+ legend=_("Accounting Address"), color=ColorScheme.ORANGE, tooltip=_("Address from which funds were swept to your wallet."))
+ self.txo_color_2fa = TxOutputColoring(
+ legend=_("TrustedCoin (2FA) batch fee"), color=ColorScheme.BLUE, tooltip=_("TrustedCoin (2FA) fee for the next batch of transactions"))
+ self.txo_color_swap = TxOutputColoring(
+ legend=_("Submarine swap address"), color=ColorScheme.BLUE, tooltip=_("Submarine swap address"))
+ self.outputs_header = QLabel()
+ self.outputs_textedit = QTextBrowserWithDefaultSize(750, 100)
+ self.outputs_textedit.setOpenLinks(False) # disable automatic link opening
+ self.outputs_textedit.anchorClicked.connect(self._open_internal_link) # send links to our handler
+ self.outputs_textedit.setTextInteractionFlags(
+ self.outputs_textedit.textInteractionFlags() | Qt.TextInteractionFlag.LinksAccessibleByMouse | Qt.TextInteractionFlag.LinksAccessibleByKeyboard)
+ self.outputs_textedit.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
+ self.outputs_textedit.customContextMenuRequested.connect(self.on_context_menu_for_outputs)
+
+ outheader_hbox = QHBoxLayout()
+ outheader_hbox.setContentsMargins(0, 0, 0, 0)
+ outheader_hbox.addWidget(self.outputs_header)
+ outheader_hbox.addStretch(2)
+ outheader_hbox.addWidget(self.txo_color_recv.legend_label)
+ outheader_hbox.addWidget(self.txo_color_change.legend_label)
+ outheader_hbox.addWidget(self.txo_color_2fa.legend_label)
+ outheader_hbox.addWidget(self.txo_color_swap.legend_label)
+ outheader_hbox.addWidget(self.txo_color_accounting.legend_label)
+
+ vbox = QVBoxLayout()
+ vbox.addLayout(self.inheader_hbox)
+ vbox.addWidget(self.inputs_textedit)
+ vbox.addLayout(outheader_hbox)
+ vbox.addWidget(self.outputs_textedit)
+ self.setLayout(vbox)
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
+
+ def update(self, tx: Optional[Transaction]):
+ self.tx = tx
+ if tx is None:
+ self.inputs_header.setText('')
+ self.inputs_textedit.setText('')
+ self.outputs_header.setText('')
+ self.outputs_textedit.setText('')
+ return
+
+ inputs_header_text = _("Inputs") + ' (%d)'%len(self.tx.inputs())
+ self.inputs_header.setText(inputs_header_text)
+ ext = QTextCharFormat() # "external"
+ lnk = QTextCharFormat()
+ lnk.setToolTip(_('Click to open, right-click for menu'))
+ lnk.setAnchor(True)
+ lnk.setUnderlineStyle(QTextCharFormat.UnderlineStyle.SingleUnderline)
+ tf_used_recv, tf_used_change, tf_used_2fa, tf_used_swap = False, False, False, False
+ tf_used_accounting = False
+
+ def addr_text_format(addr: str) -> QTextCharFormat:
+ nonlocal tf_used_recv, tf_used_change, tf_used_2fa, tf_used_swap, tf_used_accounting
+ sm = self.wallet.lnworker.swap_manager if self.wallet.lnworker else None
+ if self.wallet.is_mine(addr):
+ if self.wallet.is_change(addr):
+ tf_used_change = True
+ fmt = QTextCharFormat(self.txo_color_change.text_char_format)
+ else:
+ tf_used_recv = True
+ fmt = QTextCharFormat(self.txo_color_recv.text_char_format)
+ fmt.setAnchorHref(addr)
+ fmt.setToolTip(_('Click to open, right-click for menu'))
+ fmt.setAnchor(True)
+ fmt.setUnderlineStyle(QTextCharFormat.UnderlineStyle.SingleUnderline)
+ return fmt
+ elif sm and sm.is_lockup_address_for_a_swap(addr) or addr == DummyAddress.SWAP:
+ tf_used_swap = True
+ return self.txo_color_swap.text_char_format
+ elif self.wallet.is_billing_address(addr):
+ tf_used_2fa = True
+ return self.txo_color_2fa.text_char_format
+ elif self.wallet.is_accounting_address(addr):
+ tf_used_accounting = True
+ return self.txo_color_accounting.text_char_format
+ return ext
+
+ def insert_tx_io(
+ *,
+ cursor: QTextCursor,
+ txio_idx: int,
+ is_coinbase: bool,
+ tcf_shortid: QTextCharFormat = None,
+ short_id: str,
+ addr: Optional[str],
+ value: Optional[int],
+ ):
+ tcf_ext = QTextCharFormat(ext)
+ tcf_addr = addr_text_format(addr)
+ if tcf_shortid is None:
+ tcf_shortid = tcf_ext
+ a_name = f"txio_idx {txio_idx}"
+ for tcf in (tcf_ext, tcf_shortid, tcf_addr): # used by context menu creation
+ tcf.setAnchorNames([a_name])
+ if is_coinbase:
+ cursor.insertText('coinbase', tcf_ext)
+ else:
+ # short_id
+ cursor.insertText(short_id, tcf_shortid)
+ cursor.insertText(" " * max(0, 15 - len(short_id)), tcf_ext) # padding
+ cursor.insertText('\t', tcf_ext)
+ # addr
+ if addr is None:
+ address_str = ''
+ elif len(addr) <= 42:
+ address_str = addr
+ else:
+ address_str = addr[0:30] + '…' + addr[-11:]
+ cursor.insertText(address_str, tcf_addr)
+ cursor.insertText(" " * max(0, 42 - len(address_str)), tcf_ext) # padding
+ cursor.insertText('\t', tcf_ext)
+ # value
+ value_str = self.main_window.format_amount(value, whitespaces=True)
+ cursor.insertText(value_str, tcf_ext)
+ cursor.insertBlock()
+
+ i_text = self.inputs_textedit
+ i_text.clear()
+ i_text.setFont(QFont(MONOSPACE_FONT))
+ i_text.setReadOnly(True)
+ cursor = i_text.textCursor()
+ for txin_idx, txin in enumerate(self.tx.inputs()):
+ addr = self.wallet.adb.get_txin_address(txin)
+ txin_value = self.wallet.adb.get_txin_value(txin)
+ tcf_shortid = QTextCharFormat(lnk)
+ tcf_shortid.setAnchorHref(txin.prevout.txid.hex())
+ insert_tx_io(
+ cursor=cursor, is_coinbase=txin.is_coinbase_input(), txio_idx=txin_idx,
+ tcf_shortid=tcf_shortid,
+ short_id=str(txin.short_id), addr=addr, value=txin_value,
+ )
+
+ if isinstance(self.tx, PartialTransaction):
+ self.sighash_danger = self.wallet.check_sighash(self.tx)
+ if self.sighash_danger.risk_level >= TxSighashRiskLevel.WEIRD_SIGHASH:
+ self.sighash_label.setText(self.sighash_danger.short_message)
+ self.inputs_warning_icon.setVisible(True)
+ self.inputs_warning_icon.setToolTip(self.sighash_danger.get_long_message())
+
+ self.outputs_header.setText(_("Outputs") + ' (%d)'%len(self.tx.outputs()))
+ o_text = self.outputs_textedit
+ o_text.clear()
+ o_text.setFont(QFont(MONOSPACE_FONT))
+ o_text.setReadOnly(True)
+ tx_height, tx_pos = None, None
+ tx_hash = self.tx.txid()
+ if tx_hash:
+ tx_mined_info = self.wallet.adb.get_tx_height(tx_hash)
+ tx_height = tx_mined_info.height()
+ tx_pos = tx_mined_info.txpos
+ cursor = o_text.textCursor()
+ for txout_idx, o in enumerate(self.tx.outputs()):
+ if tx_height is not None and tx_pos is not None and tx_pos >= 0:
+ short_id = ShortID.from_components(tx_height, tx_pos, txout_idx)
+ elif tx_hash:
+ short_id = TxOutpoint(bytes.fromhex(tx_hash), txout_idx).short_name()
+ else:
+ short_id = f"unknown:{txout_idx}"
+ addr = o.get_ui_address_str()
+ spender_txid = None # type: Optional[str]
+ if tx_hash:
+ spender_txid = self.wallet.db.get_spent_outpoint(tx_hash, txout_idx)
+ tcf_shortid = None
+ if spender_txid:
+ tcf_shortid = QTextCharFormat(lnk)
+ tcf_shortid.setAnchorHref(spender_txid)
+ insert_tx_io(
+ cursor=cursor, is_coinbase=False, txio_idx=txout_idx,
+ tcf_shortid=tcf_shortid,
+ short_id=str(short_id), addr=addr, value=o.value,
+ )
+
+ self.txo_color_recv.legend_label.setVisible(tf_used_recv)
+ self.txo_color_change.legend_label.setVisible(tf_used_change)
+ self.txo_color_2fa.legend_label.setVisible(tf_used_2fa)
+ self.txo_color_swap.legend_label.setVisible(tf_used_swap)
+ self.txo_color_accounting.legend_label.setVisible(tf_used_accounting)
+
+ def _open_internal_link(self, target):
+ """Accepts either a str txid, str address, or a QUrl which should be
+ of the bare form "txid" and/or "address" -- used by the clickable
+ links in the inputs/outputs QTextBrowsers"""
+ if isinstance(target, QUrl):
+ target = target.toString(QUrl.UrlFormattingOption.None_)
+ assert target
+ if bitcoin.is_address(target):
+ # target was an address, open address dialog
+ self.main_window.show_address(target, parent=self)
+ else:
+ # target was a txid, open new tx dialog
+ self.main_window.do_process_from_txid(txid=target, parent=self)
+
+ def on_context_menu_for_inputs(self, pos: QPoint):
+ i_text = self.inputs_textedit
+ global_pos = i_text.viewport().mapToGlobal(pos)
+
+ cursor = i_text.cursorForPosition(pos)
+ charFormat = cursor.charFormat()
+ name = charFormat.anchorNames() and charFormat.anchorNames()[0]
+ if not name:
+ menu = i_text.createStandardContextMenu()
+ menu.exec(global_pos)
+ return
+
+ menu = QMenu()
+ show_list = []
+ copy_list = []
+ # figure out which input they right-clicked on. input lines have an anchor named "txio_idx N"
+ txin_idx = int(name.split()[1]) # split "txio_idx N", translate N -> int
+ txin = self.tx.inputs()[txin_idx]
+
+ menu.addAction(_("Tx Input #{}").format(txin_idx)).setDisabled(True)
+ menu.addSeparator()
+ if txin.is_coinbase_input():
+ menu.addAction(_("Coinbase Input")).setDisabled(True)
+ else:
+ show_list += [(_("Show Prev Tx"), lambda: self._open_internal_link(txin.prevout.txid.hex()))]
+ copy_list += [(_("Copy Outpoint"), lambda: self.main_window.do_copy(txin.prevout.to_str()))]
+ addr = self.wallet.adb.get_txin_address(txin)
+ if addr:
+ if self.wallet.is_mine(addr):
+ show_list += [(_("Address Details"), lambda: self.main_window.show_address(addr, parent=self))]
+ copy_list += [(_("Copy Address"), lambda: self.main_window.do_copy(addr))]
+ txin_value = self.wallet.adb.get_txin_value(txin)
+ if txin_value:
+ value_str = self.main_window.format_amount(txin_value, add_thousands_sep=False)
+ copy_list += [(_("Copy Amount"), lambda: self.main_window.do_copy(value_str))]
+
+ for item in show_list:
+ menu.addAction(*item)
+ if show_list and copy_list:
+ menu.addSeparator()
+ for item in copy_list:
+ menu.addAction(*item)
+
+ menu.addSeparator()
+ std_menu = i_text.createStandardContextMenu()
+ menu.addActions(std_menu.actions())
+ menu.exec(global_pos)
+
+ def on_context_menu_for_outputs(self, pos: QPoint):
+ o_text = self.outputs_textedit
+ global_pos = o_text.viewport().mapToGlobal(pos)
+
+ cursor = o_text.cursorForPosition(pos)
+ charFormat = cursor.charFormat()
+ name = charFormat.anchorNames() and charFormat.anchorNames()[0]
+ if not name:
+ menu = o_text.createStandardContextMenu()
+ menu.exec(global_pos)
+ return
+
+ menu = QMenu()
+ show_list = []
+ copy_list = []
+ # figure out which output they right-clicked on. output lines have an anchor named "txio_idx N"
+ txout_idx = int(name.split()[1]) # split "txio_idx N", translate N -> int
+ menu.addAction(_("Tx Output #{}").format(txout_idx)).setDisabled(True)
+ menu.addSeparator()
+ if tx_hash := self.tx.txid():
+ outpoint = TxOutpoint(bytes.fromhex(tx_hash), txout_idx)
+ copy_list += [(_("Copy Outpoint"), lambda: self.main_window.do_copy(outpoint.to_str()))]
+ if addr := self.tx.outputs()[txout_idx].address:
+ if self.wallet.is_mine(addr):
+ show_list += [(_("Address Details"), lambda: self.main_window.show_address(addr, parent=self))]
+ copy_list += [(_("Copy Address"), lambda: self.main_window.do_copy(addr))]
+ else:
+ spk = self.tx.outputs()[txout_idx].scriptpubkey
+ copy_list += [(_("Copy scriptPubKey"), lambda: self.main_window.do_copy(spk.hex()))]
+ txout_value = self.tx.outputs()[txout_idx].value
+ value_str = self.main_window.format_amount(txout_value, add_thousands_sep=False)
+ copy_list += [(_("Copy Amount"), lambda: self.main_window.do_copy(value_str))]
+
+ for item in show_list:
+ menu.addAction(*item)
+ if show_list and copy_list:
+ menu.addSeparator()
+ for item in copy_list:
+ menu.addAction(*item)
+
+ run_hook('transaction_dialog_address_menu', menu, addr, self.wallet)
+ menu.addSeparator()
+ std_menu = o_text.createStandardContextMenu()
+ menu.addActions(std_menu.actions())
+ menu.exec(global_pos)
+
+
+def show_transaction(
+ tx: Transaction,
+ *,
+ parent: 'ElectrumWindow',
+ prompt_if_unsaved: bool = False,
+ prompt_if_complete_unsaved: bool = True,
+ external_keypairs: Mapping[bytes, bytes] = None,
+ invoice: 'Invoice' = None,
+ on_closed: Callable[[Optional[Transaction]], None] = None,
+ show_sign_button: bool = True,
+ show_broadcast_button: bool = True,
+):
+ try:
+ d = TxDialog(
+ tx,
+ parent=parent,
+ prompt_if_unsaved=prompt_if_unsaved,
+ prompt_if_complete_unsaved=prompt_if_complete_unsaved,
+ external_keypairs=external_keypairs,
+ invoice=invoice,
+ on_closed=on_closed,
+ )
+ if not show_sign_button:
+ d.sign_button.setVisible(False)
+ if not show_broadcast_button:
+ d.broadcast_button.setVisible(False)
+ except SerializationError as e:
+ _logger.exception('unable to deserialize the transaction')
+ parent.show_critical(_("Electrum was unable to deserialize the transaction:") + "\n" + str(e))
+ except UserCancelled:
+ return
+ else:
+ d.show()
+
+
+class TxDialog(QDialog, MessageBoxMixin):
+
+ throttled_update_sig = pyqtSignal() # emit from thread to do update in main thread
+
+ def __init__(
+ self,
+ tx: Transaction,
+ *,
+ parent: 'ElectrumWindow',
+ prompt_if_unsaved: bool,
+ prompt_if_complete_unsaved: bool = True,
+ external_keypairs: Mapping[bytes, bytes] = None,
+ invoice: 'Invoice' = None,
+ on_closed: Callable[[Optional[Transaction]], None] = None,
+ ):
+ '''Transactions in the wallet will show their description.
+ Pass desc to give a description for txs not yet in the wallet.
+ '''
+ # We want to be a top-level window
+ QDialog.__init__(self, parent=None)
+ self.tx = None # type: Optional[Transaction]
+ self.external_keypairs = external_keypairs
+ self.main_window = parent
+ self.config = parent.config
+ self.wallet = parent.wallet
+ self.invoice = invoice
+ self.prompt_if_unsaved = prompt_if_unsaved
+ self.prompt_if_complete_unsaved = prompt_if_complete_unsaved
+ self.on_closed = on_closed
+ self.saved = False
+ self.desc = None
+ if txid := tx.txid():
+ self.desc = self.wallet.get_label_for_txid(txid) or None
+ if not self.desc and self.invoice:
+ self.desc = self.invoice.get_message()
+ self.setMinimumWidth(640)
+
+ self.psbt_only_widgets = [] # type: List[Union[QWidget, QAction]]
+
+ vbox = QVBoxLayout()
+ self.setLayout(vbox)
+ toolbar, menu = create_toolbar_with_menu(self.config, '')
+ menu.addConfig(
+ self.config.cv.GUI_QT_TX_DIALOG_FETCH_TXIN_DATA,
+ callback=self.maybe_fetch_txin_data)
+ vbox.addLayout(toolbar)
+
+ vbox.addWidget(QLabel(_("Transaction ID:")))
+ self.tx_hash_e = ShowQRLineEdit('', self.config, title=_('Transaction ID'))
+ vbox.addWidget(self.tx_hash_e)
+ self.tx_desc_label = QLabel(_("Description:"))
+ vbox.addWidget(self.tx_desc_label)
+ self.tx_desc = ButtonsLineEdit('')
+
+ self.tx_desc.editingFinished.connect(self.store_tx_label)
+ self.tx_desc.addCopyButton()
+ vbox.addWidget(self.tx_desc)
+
+ self.add_tx_stats(vbox)
+
+ vbox.addSpacing(10)
+
+ self.io_widget = TxInOutWidget(self.main_window, self.wallet)
+ vbox.addWidget(self.io_widget)
+
+ self.sign_button = b = QPushButton(_("Sign"))
+ b.clicked.connect(self.sign)
+
+ self.broadcast_button = b = QPushButton(_("Broadcast"))
+ b.clicked.connect(self.do_broadcast)
+
+ self.save_button = b = QPushButton(_("Add to History"))
+ b.clicked.connect(self.save)
+
+ self.cancel_button = b = QPushButton(_("Close"))
+ b.clicked.connect(self.close)
+ b.setDefault(True)
+
+ self.export_actions_menu = export_actions_menu = QMenuWithConfig(config=self.config)
+ self.add_export_actions_to_menu(export_actions_menu)
+ export_actions_menu.addSeparator()
+ export_option = export_actions_menu.addConfig(
+ self.config.cv.GUI_QT_TX_DIALOG_EXPORT_STRIP_SENSITIVE_METADATA)
+ self.psbt_only_widgets.append(export_option)
+ export_option = export_actions_menu.addConfig(
+ self.config.cv.GUI_QT_TX_DIALOG_EXPORT_INCLUDE_GLOBAL_XPUBS)
+ self.psbt_only_widgets.append(export_option)
+ if self.wallet.has_support_for_slip_19_ownership_proofs():
+ export_option = export_actions_menu.addAction(
+ _('Include SLIP-19 ownership proofs'),
+ self._add_slip_19_ownership_proofs_to_tx)
+ export_option.setToolTip(_("Some cosigners (e.g. Trezor) might require this for coinjoins."))
+ self._export_option_slip19 = export_option
+ export_option.setCheckable(True)
+ export_option.setChecked(False)
+ self.psbt_only_widgets.append(export_option)
+
+ self.export_actions_button = QToolButton()
+ self.export_actions_button.setText(_("Share"))
+ self.export_actions_button.setMenu(export_actions_menu)
+ self.export_actions_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
+
+ partial_tx_actions_menu = QMenu()
+ ptx_merge_sigs_action = QAction(_("Merge signatures from"), self)
+ ptx_merge_sigs_action.triggered.connect(self.merge_sigs)
+ partial_tx_actions_menu.addAction(ptx_merge_sigs_action)
+ self._ptx_join_txs_action = QAction(_("Join inputs/outputs"), self)
+ self._ptx_join_txs_action.triggered.connect(self.join_tx_with_another)
+ partial_tx_actions_menu.addAction(self._ptx_join_txs_action)
+ self.partial_tx_actions_button = QToolButton()
+ self.partial_tx_actions_button.setText(_("Combine"))
+ self.partial_tx_actions_button.setMenu(partial_tx_actions_menu)
+ self.partial_tx_actions_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
+ self.psbt_only_widgets.append(self.partial_tx_actions_button)
+
+ # Action buttons
+ self.buttons = [self.partial_tx_actions_button, self.sign_button, self.broadcast_button, self.cancel_button]
+ # Transaction sharing buttons
+ self.sharing_buttons = [self.export_actions_button, self.save_button]
+ run_hook('transaction_dialog', self)
+ self.hbox = hbox = QHBoxLayout()
+ hbox.addLayout(Buttons(*self.sharing_buttons))
+ hbox.addStretch(1)
+ hbox.addLayout(Buttons(*self.buttons))
+ vbox.addLayout(hbox)
+ dialogs.append(self)
+
+ self._fetch_txin_data_fut = None # type: Optional[concurrent.futures.Future]
+ self._fetch_txin_data_progress = None # type: Optional[TxinDataFetchProgress]
+ self.throttled_update_sig.connect(self._throttled_update, Qt.ConnectionType.QueuedConnection)
+
+ self.set_tx(tx)
+ self.update()
+ self.set_title()
+
+ def store_tx_label(self):
+ text = self.tx_desc.text()
+ if self.wallet.set_label(self.tx.txid(), text):
+ self.main_window.history_list.update()
+ self.main_window.utxo_list.update()
+ self.main_window.labels_changed_signal.emit()
+
+ def set_tx(self, tx: 'Transaction'):
+ # Take a copy; it might get updated in the main window by
+ # e.g. the FX plugin. If this happens during or after a long
+ # sign operation the signatures are lost.
+ self.tx = tx = copy.deepcopy(tx)
+ try:
+ self.tx.deserialize()
+ except BaseException as e:
+ raise SerializationError(e)
+ # If the wallet can populate the inputs with more info, do it now.
+ # As a result, e.g. we might learn an imported address tx is segwit,
+ # or that a beyond-gap-limit address is is_mine.
+ # note: this might fetch prev txs over the network.
+ tx.add_info_from_wallet(self.wallet)
+ # FIXME for PSBTs, we do a blocking fetch, as the missing data might be needed for e.g. signing
+ # - otherwise, the missing data is for display-completeness only, e.g. fee, input addresses (we do it async)
+ if not tx.is_complete() and tx.is_missing_info_from_network():
+ self.main_window.run_coroutine_dialog(
+ tx.add_info_from_network(self.wallet.network, timeout=10),
+ _("Adding info to tx, from network..."),
+ )
+ else:
+ self.maybe_fetch_txin_data()
+
+ def do_broadcast(self):
+ self.main_window.push_top_level_window(self)
+ self.main_window.send_tab.save_pending_invoice()
+ try:
+ self.main_window.broadcast_transaction(self.tx, invoice=self.invoice)
+ finally:
+ self.main_window.pop_top_level_window(self)
+ self.saved = True
+ self.update()
+
+ def closeEvent(self, event):
+ if (self.prompt_if_unsaved and not self.saved
+ and not self.question(_('This transaction is not saved. Close anyway?'), title=_("Warning"))):
+ event.ignore()
+ else:
+ event.accept()
+ try:
+ dialogs.remove(self)
+ except ValueError:
+ pass # was not in list already
+ if self._fetch_txin_data_fut:
+ self._fetch_txin_data_fut.cancel()
+ self._fetch_txin_data_fut = None
+
+ if self.on_closed:
+ self.on_closed(self.tx)
+
+ def reject(self):
+ # Override escape-key to close normally (and invoke closeEvent)
+ self.close()
+
+ def add_export_actions_to_menu(self, menu: QMenu) -> None:
+ def gettx() -> Transaction:
+ if not isinstance(self.tx, PartialTransaction):
+ return self.tx
+ tx = copy.deepcopy(self.tx)
+ if self.config.GUI_QT_TX_DIALOG_EXPORT_INCLUDE_GLOBAL_XPUBS:
+ Network.run_from_another_thread(
+ tx.prepare_for_export_for_hardware_device(self.wallet))
+ if self.config.GUI_QT_TX_DIALOG_EXPORT_STRIP_SENSITIVE_METADATA:
+ tx.prepare_for_export_for_coinjoin()
+ return tx
+
+ action = QAction(_("Copy to clipboard"), self)
+ action.triggered.connect(lambda: self.copy_to_clipboard(tx=gettx()))
+ menu.addAction(action)
+
+ action = QAction(get_icon_qrcode(), _("Show as QR code"), self)
+ action.triggered.connect(lambda: self.show_qr(tx=gettx()))
+ menu.addAction(action)
+
+ action = QAction(_("Save to file"), self)
+ action.triggered.connect(lambda: self.export_to_file(tx=gettx()))
+ menu.addAction(action)
+
+ def _add_slip_19_ownership_proofs_to_tx(self):
+ assert isinstance(self.tx, PartialTransaction)
+
+ def on_success(result):
+ self._export_option_slip19.setEnabled(False)
+ self.main_window.pop_top_level_window(self)
+
+ def on_failure(exc_info):
+ self._export_option_slip19.setChecked(False)
+ self.main_window.on_error(exc_info)
+ self.main_window.pop_top_level_window(self)
+ task = partial(self.wallet.add_slip_19_ownership_proofs_to_tx, self.tx)
+ msg = _('Adding SLIP-19 ownership proofs to transaction...')
+ self.main_window.push_top_level_window(self)
+ WaitingDialog(self, msg, task, on_success, on_failure)
+
+ def copy_to_clipboard(self, *, tx: Transaction = None):
+ if tx is None:
+ tx = self.tx
+ self.main_window.do_copy(str(tx), title=_("Transaction"))
+
+ def show_qr(self, *, tx: Transaction = None):
+ if tx is None:
+ tx = self.tx
+ qr_data, is_complete = tx.to_qr_data()
+ help_text = None
+ if not is_complete:
+ help_text = _(
+ """Warning: Some data (prev txs / "full utxos") was left """
+ """out of the QR code as it would not fit. This might cause issues if signing offline. """
+ """As a workaround, try exporting the tx as file or text instead.""")
+ try:
+ self.main_window.show_qrcode(qr_data, _("Transaction"), parent=self, help_text=help_text)
+ except qrcode.exceptions.DataOverflowError:
+ self.show_error(_('Failed to display QR code.') + '\n' +
+ _('Transaction is too large in size.'))
+ except Exception as e:
+ self.show_error(_('Failed to display QR code.') + '\n' + repr(e))
+
+ def sign(self):
+ def sign_done(success):
+ if self.tx.is_complete() and self.prompt_if_complete_unsaved:
+ self.prompt_if_unsaved = True
+ self.saved = False
+ self.update()
+ self.main_window.pop_top_level_window(self)
+
+ if self.io_widget.sighash_danger.needs_confirm():
+ if not self.question(
+ msg='\n'.join([
+ self.io_widget.sighash_danger.get_long_message(),
+ '',
+ _('Are you sure you want to sign this transaction?')
+ ]),
+ title=self.io_widget.sighash_danger.short_message,
+ ):
+ return
+ self.sign_button.setDisabled(True)
+ self.main_window.push_top_level_window(self)
+ self.main_window.sign_tx(self.tx, callback=sign_done, external_keypairs=self.external_keypairs)
+
+ def save(self):
+ self.main_window.push_top_level_window(self)
+ if self.main_window.save_transaction_into_wallet(self.tx):
+ self.store_tx_label()
+ self.save_button.setDisabled(True)
+ self.saved = True
+ self.main_window.pop_top_level_window(self)
+
+ def export_to_file(self, *, tx: Transaction = None):
+ if tx is None:
+ tx = self.tx
+ if isinstance(tx, PartialTransaction):
+ tx.finalize_psbt()
+ txid = tx.txid()
+ suffix = txid[0:8] if txid is not None else time.strftime('%Y%m%d-%H%M')
+ if tx.is_complete():
+ extension = 'txn'
+ default_filter = TRANSACTION_FILE_EXTENSION_FILTER_ONLY_COMPLETE_TX
+ else:
+ extension = 'psbt'
+ default_filter = TRANSACTION_FILE_EXTENSION_FILTER_ONLY_PARTIAL_TX
+ name = f'{self.wallet.basename()}-{suffix}.{extension}'
+ fileName = getSaveFileName(
+ parent=self,
+ title=_("Select where to save your transaction"),
+ filename=name,
+ filter=TRANSACTION_FILE_EXTENSION_FILTER_SEPARATE,
+ default_extension=extension,
+ default_filter=default_filter,
+ config=self.config,
+ )
+ if not fileName:
+ return
+ if tx.is_complete(): # network tx hex
+ with open(fileName, "w+") as f:
+ network_tx_hex = tx.serialize_to_network()
+ f.write(network_tx_hex + '\n')
+ else: # if partial: PSBT bytes
+ assert isinstance(tx, PartialTransaction)
+ with open(fileName, "wb+") as f:
+ f.write(tx.serialize_as_bytes())
+
+ self.show_message(_("Transaction exported successfully"))
+ self.saved = True
+
+ def merge_sigs(self):
+ if not isinstance(self.tx, PartialTransaction):
+ return
+ text = text_dialog(
+ parent=self,
+ title=_('Input raw transaction'),
+ header_layout=_("Transaction to merge signatures from") + ":",
+ ok_label=_("Load transaction"),
+ config=self.config,
+ )
+ if not text:
+ return
+ tx = self.main_window.tx_from_text(text)
+ if not tx:
+ return
+ try:
+ self.tx.combine_with_other_psbt(tx)
+ except Exception as e:
+ self.show_error(_("Error combining partial transactions") + ":\n" + repr(e))
+ return
+ self.update()
+
+ def join_tx_with_another(self):
+ if not isinstance(self.tx, PartialTransaction):
+ return
+ text = text_dialog(
+ parent=self,
+ title=_('Input raw transaction'),
+ header_layout=_("Transaction to join with") + " (" + _("add inputs and outputs") + "):",
+ ok_label=_("Load transaction"),
+ config=self.config,
+ )
+ if not text:
+ return
+ tx = self.main_window.tx_from_text(text)
+ if not tx:
+ return
+ try:
+ self.tx.join_with_other_psbt(tx, config=self.config)
+ except Exception as e:
+ self.show_error(_("Error joining partial transactions") + ":\n" + repr(e))
+ return
+ self.update()
+
+ @rate_limited(0.5, ts_after=True)
+ def _throttled_update(self):
+ self.update()
+
+ def update(self):
+ if self.tx is None:
+ return
+ self.io_widget.update(self.tx)
+ desc = self.desc
+ base_unit = self.main_window.base_unit()
+ format_amount = self.main_window.format_amount
+ format_fiat_and_units = self.main_window.format_fiat_and_units
+ tx_details = self.wallet.get_tx_info(self.tx)
+ tx_mined_status = tx_details.tx_mined_status
+ exp_n = tx_details.mempool_depth_bytes
+ amount, fee = tx_details.amount, tx_details.fee
+ size = self.tx.estimated_size()
+ txid = self.tx.txid()
+ fx = self.main_window.fx
+ tx_item_fiat = None
+ if txid is not None and fx.is_enabled() and amount is not None:
+ tx_item_fiat = self.wallet.get_tx_item_fiat(
+ tx_hash=txid, amount_sat=abs(amount), fx=fx, tx_fee=fee)
+
+ if self.wallet.lnworker and txid:
+ # if it is a group, collect ln amount
+ full_history = self.wallet.get_full_history()
+ item = full_history.get('group:' + txid)
+ ln_amount = item['ln_value'].value if item else None
+ else:
+ ln_amount = None
+
+ self.broadcast_button.setEnabled(tx_details.can_broadcast)
+ can_sign = not self.tx.is_complete() and \
+ (self.wallet.can_sign(self.tx) or bool(self.external_keypairs))
+ self.sign_button.setEnabled(can_sign and not self.io_widget.sighash_danger.needs_reject())
+ if sh_danger_msg := self.io_widget.sighash_danger.get_long_message():
+ self.sign_button.setToolTip(sh_danger_msg)
+ if tx_details.txid:
+ self.tx_hash_e.setText(tx_details.txid)
+ else:
+ # note: when not finalized, RBF and locktime changes do not trigger
+ # a make_tx, so the txid is unreliable, hence:
+ self.tx_hash_e.setText(_('Unknown'))
+ tx_in_db = bool(self.wallet.adb.get_transaction(txid))
+ if not desc and not tx_in_db:
+ self.tx_desc.hide()
+ self.tx_desc_label.hide()
+ else:
+ self.tx_desc.setText(desc)
+ self.tx_desc.show()
+ self.tx_desc_label.show()
+ self.status_label.setText(_('Status: {}').format(tx_details.status))
+
+ if tx_mined_status.timestamp:
+ time_str = datetime.datetime.fromtimestamp(tx_mined_status.timestamp).isoformat(' ')[:-3]
+ self.date_label.setText(_("Date: {}").format(time_str))
+ self.date_label.show()
+ elif exp_n is not None:
+ from electrum.fee_policy import FeePolicy
+ self.date_label.setText(_('Position in mempool: {}').format(FeePolicy.depth_tooltip(exp_n)))
+ self.date_label.show()
+ else:
+ self.date_label.hide()
+ if self.tx.locktime <= NLOCKTIME_BLOCKHEIGHT_MAX:
+ locktime_str = _('height')
+ else:
+ locktime_str = datetime.datetime.fromtimestamp(self.tx.locktime)
+ locktime_final_str = _("LockTime: {} ({})").format(self.tx.locktime, locktime_str)
+ self.locktime_final_label.setText(locktime_final_str)
+
+ nsequence_time = self.tx.get_time_based_relative_locktime()
+ nsequence_blocks = self.tx.get_block_based_relative_locktime()
+ if nsequence_time or nsequence_blocks:
+ if nsequence_time:
+ seconds = nsequence_time * 512
+ time_str = delta_time_str(datetime.timedelta(seconds=seconds))
+ else:
+ time_str = '{} blocks'.format(nsequence_blocks)
+ nsequence_str = _("Relative locktime: {}").format(time_str)
+ self.nsequence_label.setText(nsequence_str)
+ else:
+ self.nsequence_label.hide()
+
+ # TODO: 'Yes'/'No' might be better translatable than 'True'/'False'?
+ self.rbf_label.setText(_('Replace by fee: {}').format(_('True') if self.tx.is_rbf_enabled() else _('False')))
+
+ if tx_mined_status.header_hash:
+ self.block_height_label.setText(_("At block height: {}").format(tx_mined_status.height()))
+ else:
+ self.block_height_label.hide()
+ if amount is None and ln_amount is None:
+ amount_str = _("Transaction unrelated to your wallet")
+ elif amount is None:
+ amount_str = ''
+ else:
+ amount_str = ''
+ if fx.is_enabled():
+ if tx_item_fiat: # historical tx -> using historical price
+ amount_str += ' ({})'.format(tx_item_fiat['fiat_value'].to_ui_string())
+ elif tx_details.is_related_to_wallet: # probably "tx preview" -> using current price
+ amount_str += ' ({})'.format(format_fiat_and_units(abs(amount)))
+ amount_str = format_amount(abs(amount)) + ' ' + base_unit + amount_str
+ if amount > 0:
+ amount_str = _("Amount received: {}").format(amount_str)
+ else:
+ amount_str = _("Amount sent: {}").format(amount_str)
+ if amount_str:
+ self.amount_label.setText(amount_str)
+ else:
+ self.amount_label.hide()
+ size_str = _("Size: {} {}").format(size, UI_UNIT_NAME_TXSIZE_VBYTES)
+ if fee is None:
+ if prog := self._fetch_txin_data_progress:
+ if not prog.has_errored:
+ fee_str = _("Downloading input data... {}").format(f"({prog.num_tasks_done}/{prog.num_tasks_total})")
+ else:
+ fee_str = _("Downloading input data... {}").format(_("error"))
+ else:
+ fee_str = _("Fee: {}").format(_("unknown"))
+ else:
+ fee_str = _("Fee: {}").format(f'{format_amount(fee)} {base_unit}')
+ if fx.is_enabled():
+ if tx_item_fiat: # historical tx -> using historical price
+ fee_str += ' ({})'.format(tx_item_fiat['fiat_fee'].to_ui_string())
+ elif tx_details.is_related_to_wallet: # probably "tx preview" -> using current price
+ fee_str += ' ({})'.format(format_fiat_and_units(fee))
+
+ fee_rate = Decimal(fee) / size # sat/byte
+ fee_str += ' ( %s ) ' % self.main_window.format_fee_rate(fee_rate * 1000)
+ if isinstance(self.tx, PartialTransaction):
+ # 'amount' is zero for self-payments, so in that case we use sum-of-outputs
+ invoice_amt = abs(amount) if amount else self.tx.output_value()
+ fee_warning_tuple = self.wallet.get_tx_fee_warning(
+ invoice_amt=invoice_amt, tx_size=size, fee=fee, txid=self.tx.txid())
+ if fee_warning_tuple:
+ allow_send, long_warning, short_warning = fee_warning_tuple
+ fee_str += " - {header}: {body}".format(
+ header=_('Warning'),
+ body=short_warning,
+ color=ColorScheme.RED.as_color().name(),
+ )
+ if isinstance(self.tx, PartialTransaction):
+ sh_warning = self.io_widget.sighash_danger.get_long_message()
+ self.fee_warning_icon.setToolTip(str(sh_warning))
+ self.fee_warning_icon.setVisible(can_sign and bool(sh_warning))
+ self.fee_label.setText(fee_str)
+ self.size_label.setText(size_str)
+ if ln_amount is None or ln_amount == 0:
+ ln_amount_str = ''
+ elif ln_amount > 0:
+ ln_amount_str = _('Amount received in channels: {}').format(format_amount(ln_amount) + ' ' + base_unit)
+ else:
+ assert ln_amount < 0, f"{ln_amount!r}"
+ ln_amount_str = _('Amount withdrawn from channels: {}').format(format_amount(-ln_amount) + ' ' + base_unit)
+ if ln_amount_str:
+ self.ln_amount_label.setText(ln_amount_str)
+ else:
+ self.ln_amount_label.hide()
+ show_psbt_only_widgets = isinstance(self.tx, PartialTransaction)
+ for widget in self.psbt_only_widgets:
+ if isinstance(widget, QMenu):
+ widget.menuAction().setVisible(show_psbt_only_widgets)
+ else:
+ widget.setVisible(show_psbt_only_widgets)
+ if tx_details.is_lightning_funding_tx:
+ self._ptx_join_txs_action.setEnabled(False) # would change txid
+
+ self.save_button.setEnabled(tx_details.can_save_as_local)
+ if tx_details.can_save_as_local:
+ self.save_button.setToolTip(_("Add transaction to history, without broadcasting it"))
+ else:
+ self.save_button.setToolTip(_("Transaction already in history or not yet signed."))
+
+ run_hook('transaction_dialog_update', self)
+
+ def add_tx_stats(self, vbox):
+ hbox_stats = QHBoxLayout()
+ hbox_stats.setContentsMargins(0, 0, 0, 0)
+ hbox_stats_w = QWidget()
+ hbox_stats_w.setLayout(hbox_stats)
+ hbox_stats_w.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
+
+ # left column
+ vbox_left = QVBoxLayout()
+ self.status_label = TxDetailLabel()
+ vbox_left.addWidget(self.status_label)
+ self.date_label = TxDetailLabel()
+ vbox_left.addWidget(self.date_label)
+ self.amount_label = TxDetailLabel()
+ vbox_left.addWidget(self.amount_label)
+ self.ln_amount_label = TxDetailLabel()
+ vbox_left.addWidget(self.ln_amount_label)
+
+ fee_hbox = QHBoxLayout()
+ self.fee_label = TxDetailLabel()
+ fee_hbox.addWidget(self.fee_label)
+ self.fee_warning_icon = QLabel()
+ pixmap = QPixmap(icon_path("warning"))
+ pixmap_size = round(2 * char_width_in_lineedit())
+ pixmap = pixmap.scaled(pixmap_size, pixmap_size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
+ self.fee_warning_icon.setPixmap(pixmap)
+ self.fee_warning_icon.setVisible(False)
+ fee_hbox.addWidget(self.fee_warning_icon)
+ fee_hbox.addStretch(1)
+ vbox_left.addLayout(fee_hbox)
+
+ vbox_left.addStretch(1)
+ hbox_stats.addLayout(vbox_left, 50)
+
+ # vertical line separator
+ hbox_stats.addWidget(VLine())
+
+ # right column
+ vbox_right = QVBoxLayout()
+ self.size_label = TxDetailLabel()
+ vbox_right.addWidget(self.size_label)
+ self.rbf_label = TxDetailLabel()
+ vbox_right.addWidget(self.rbf_label)
+
+ self.locktime_final_label = TxDetailLabel()
+ vbox_right.addWidget(self.locktime_final_label)
+
+ self.nsequence_label = TxDetailLabel()
+ vbox_right.addWidget(self.nsequence_label)
+
+ self.block_height_label = TxDetailLabel()
+ vbox_right.addWidget(self.block_height_label)
+ vbox_right.addStretch(1)
+ hbox_stats.addLayout(vbox_right, 50)
+
+ vbox.addWidget(hbox_stats_w)
+
+ # set visibility after parenting can be determined by Qt
+ self.rbf_label.setVisible(True)
+ self.locktime_final_label.setVisible(True)
+
+ def set_title(self):
+ txid = self.tx.txid() or ""
+ self.setWindowTitle(_("Transaction") + ' ' + txid)
+
+ def maybe_fetch_txin_data(self):
+ """Download missing input data from the network, asynchronously.
+ Note: we fetch the prev txs, which allows calculating the fee and showing "input addresses".
+ We could also SPV-verify the tx, to fill in missing tx_mined_status (block height, blockhash, timestamp),
+ but this is not done currently.
+ """
+ if not self.config.GUI_QT_TX_DIALOG_FETCH_TXIN_DATA:
+ return
+ tx = self.tx
+ if not tx:
+ return
+ if self._fetch_txin_data_fut is not None:
+ return
+ network = self.wallet.network
+
+ def progress_cb(prog: TxinDataFetchProgress):
+ self._fetch_txin_data_progress = prog
+ self.throttled_update_sig.emit()
+
+ async def wrapper():
+ try:
+ await tx.add_info_from_network(network, progress_cb=progress_cb)
+ finally:
+ self._fetch_txin_data_fut = None
+
+ self._fetch_txin_data_progress = None
+ self._fetch_txin_data_fut = asyncio.run_coroutine_threadsafe(wrapper(), get_asyncio_loop())
+
+
+class TxDetailLabel(QLabel):
+ def __init__(self, *, word_wrap=None):
+ super().__init__()
+ self.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
+ if word_wrap is not None:
+ self.setWordWrap(word_wrap)
+
+
+class TxOutputColoring:
+ # used for both inputs and outputs
+
+ def __init__(
+ self,
+ *,
+ legend: str,
+ color: ColorSchemeItem,
+ tooltip: str,
+ ):
+ self.color = color.as_color(background=True)
+ self.legend_label = QLabel("{box_char} = {label}".format(
+ color=self.color.name(),
+ box_char="█",
+ label=legend,
+ ))
+ font = self.legend_label.font()
+ font.setPointSize(font.pointSize() - 1)
+ self.legend_label.setFont(font)
+ self.legend_label.setVisible(False)
+ self.text_char_format = QTextCharFormat()
+ self.text_char_format.setBackground(QBrush(self.color))
+ self.text_char_format.setToolTip(tooltip)
+
diff --git a/electrum/gui/qt/update_checker.py b/electrum/gui/qt/update_checker.py
new file mode 100644
index 000000000000..f3c918c5bc07
--- /dev/null
+++ b/electrum/gui/qt/update_checker.py
@@ -0,0 +1,154 @@
+# Copyright (C) 2019 The Electrum developers
+# Distributed under the MIT software license, see the accompanying
+# file LICENCE or http://www.opensource.org/licenses/mit-license.php
+
+import asyncio
+import base64
+from typing import Optional
+
+from PyQt6.QtCore import Qt, QThread, pyqtSignal
+from PyQt6.QtWidgets import QVBoxLayout, QLabel, QProgressBar, QHBoxLayout, QPushButton, QDialog
+
+from electrum import version
+from electrum import constants
+from electrum.bitcoin import verify_usermessage_with_address
+from electrum.i18n import _
+from electrum.util import make_aiohttp_session
+from electrum.logging import Logger
+from electrum.network import Network
+from electrum._vendor.distutils.version import StrictVersion
+
+
+class UpdateCheck(QDialog, Logger):
+ url = "https://electrum.org/version"
+ download_url = "https://electrum.org/#download"
+
+ VERSION_ANNOUNCEMENT_SIGNING_KEYS = (
+ "13xjmVAB1EATPP8RshTE8S8sNwwSUM9p1P", # ThomasV (since 3.3.4)
+ "1Nxgk6NTooV4qZsX5fdqQwrLjYcsQZAfTg", # ghost43 (since 4.1.2)
+ )
+
+ def __init__(self, *, latest_version=None):
+ QDialog.__init__(self)
+ self.setWindowTitle('Electrum - ' + _('Update Check'))
+ self.content = QVBoxLayout()
+ self.content.setContentsMargins(*[10]*4)
+
+ self.heading_label = QLabel()
+ self.content.addWidget(self.heading_label)
+
+ self.detail_label = QLabel()
+ self.detail_label.setTextInteractionFlags(Qt.TextInteractionFlag.LinksAccessibleByMouse)
+ self.detail_label.setOpenExternalLinks(True)
+ self.content.addWidget(self.detail_label)
+
+ self.pb = QProgressBar()
+ self.pb.setMaximum(0)
+ self.pb.setMinimum(0)
+ self.content.addWidget(self.pb)
+
+ versions = QHBoxLayout()
+ versions.addWidget(QLabel(_("Current version: {}").format(version.ELECTRUM_VERSION)))
+ self.latest_version_label = QLabel(_("Latest version: {}").format(" "))
+ versions.addWidget(self.latest_version_label)
+ self.content.addLayout(versions)
+
+ self.update_view(latest_version)
+
+ self.update_check_thread = UpdateCheckThread()
+ self.update_check_thread.checked.connect(self.on_version_retrieved)
+ self.update_check_thread.failed.connect(self.on_retrieval_failed)
+ self.update_check_thread.start()
+
+ close_button = QPushButton(_("Close"))
+ close_button.clicked.connect(self.close)
+ self.content.addWidget(close_button)
+ self.setLayout(self.content)
+ self.show()
+
+ def on_version_retrieved(self, version):
+ self.update_view(version)
+
+ def on_retrieval_failed(self):
+ self.heading_label.setText('
' + _("Update check failed") + '
')
+ self.detail_label.setText(_("Sorry, but we were unable to check for updates. Please try again later."))
+ self.pb.hide()
+
+ @staticmethod
+ def is_newer(latest_version):
+ return latest_version > StrictVersion(version.ELECTRUM_VERSION)
+
+ def update_view(self, latest_version=None):
+ if latest_version:
+ self.pb.hide()
+ self.latest_version_label.setText(_("Latest version: {}").format(latest_version))
+ if self.is_newer(latest_version):
+ self.heading_label.setText('
' + _("There is a new update available") + '
')
+ url = "{u}".format(u=UpdateCheck.download_url)
+ self.detail_label.setText(_("You can download the new version from {}.").format(url))
+ else:
+ self.heading_label.setText('
' + _("Already up to date") + '
')
+ self.detail_label.setText(_("You are already on the latest version of Electrum."))
+ else:
+ self.heading_label.setText('
' + _("Checking for updates...") + '
')
+ self.detail_label.setText(_("Please wait while Electrum checks for available updates."))
+
+
+class UpdateCheckThread(QThread, Logger):
+ checked = pyqtSignal(object)
+ failed = pyqtSignal()
+
+ def __init__(self):
+ QThread.__init__(self)
+ Logger.__init__(self)
+ self.network = Network.get_instance()
+ self._fut = None # type: Optional[asyncio.Future]
+
+ async def get_update_info(self):
+ # note: Use long timeout here as it is not critical that we get a response fast,
+ # and it's bad not to get an update notification just because we did not wait enough.
+ async with make_aiohttp_session(proxy=self.network.proxy, timeout=120) as session:
+ async with session.get(UpdateCheck.url) as result:
+ signed_version_dict = await result.json(content_type=None)
+ # example signed_version_dict:
+ # {
+ # "version": "3.9.9",
+ # "signatures": {
+ # "1Lqm1HphuhxKZQEawzPse8gJtgjm9kUKT4": "IA+2QG3xPRn4HAIFdpu9eeaCYC7S5wS/sDxn54LJx6BdUTBpse3ibtfq8C43M7M1VfpGkD5tsdwl5C6IfpZD/gQ="
+ # }
+ # }
+ version_num = signed_version_dict['version']
+ sigs = signed_version_dict['signatures']
+ for address, sig in sigs.items():
+ if address not in UpdateCheck.VERSION_ANNOUNCEMENT_SIGNING_KEYS:
+ continue
+ sig = base64.b64decode(sig, validate=True)
+ msg = version_num.encode('utf-8')
+ if verify_usermessage_with_address(
+ address=address, sig65=sig, message=msg,
+ net=constants.BitcoinMainnet
+ ):
+ self.logger.info(f"valid sig for version announcement '{version_num}' from address '{address}'")
+ break
+ else:
+ raise Exception('no valid signature for version announcement')
+ return StrictVersion(version_num.strip())
+
+ def run(self):
+ if not self.network:
+ self.failed.emit()
+ return
+ self._fut = asyncio.run_coroutine_threadsafe(self.get_update_info(), self.network.asyncio_loop)
+ try:
+ update_info = self._fut.result()
+ except Exception as e:
+ self.logger.info(f"got exception: '{repr(e)}'")
+ self.failed.emit()
+ else:
+ self.checked.emit(update_info)
+
+ def stop(self):
+ if self._fut:
+ self._fut.cancel()
+ self.exit()
+ self.wait()
diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py
new file mode 100644
index 000000000000..2a62395245f7
--- /dev/null
+++ b/electrum/gui/qt/util.py
@@ -0,0 +1,1528 @@
+from abc import ABC, ABCMeta
+import os.path
+import time
+import sys
+import platform
+import queue
+import os
+import webbrowser
+import ctypes
+from functools import partial, lru_cache
+from typing import (NamedTuple, Callable, Optional, TYPE_CHECKING, List, Any, Sequence, Tuple, Union)
+
+from PyQt6 import QtCore
+from PyQt6.QtGui import (QFont, QColor, QCursor, QPixmap, QImage,
+ QPalette, QIcon, QFontMetrics, QPainter, QContextMenuEvent, QMovie)
+from PyQt6.QtCore import (Qt, pyqtSignal, QCoreApplication, QSize, QRect, QPoint, QObject)
+from PyQt6.QtWidgets import (QPushButton, QLabel, QMessageBox, QHBoxLayout, QVBoxLayout, QLineEdit,
+ QStyle, QDialog, QGroupBox, QButtonGroup, QRadioButton,
+ QFileDialog, QWidget, QToolButton, QPlainTextEdit, QApplication, QToolTip,
+ QGraphicsEffect, QGraphicsScene, QGraphicsPixmapItem, QLayoutItem, QLayout, QMenu,
+ QFrame, QAbstractButton)
+
+from electrum.i18n import _
+from electrum.util import (FileImportFailed, FileExportFailed, resource_path, EventListener,
+ get_logger, UserCancelled, UserFacingException, ChoiceItem)
+from electrum.invoices import (PR_UNPAID, PR_PAID, PR_EXPIRED, PR_INFLIGHT, PR_UNKNOWN, PR_FAILED, PR_ROUTING,
+ PR_UNCONFIRMED, PR_BROADCASTING, PR_BROADCAST)
+from electrum.qrreader import MissingQrDetectionLib, QrCodeResult
+from electrum.submarine_swaps import pubkey_to_rgb_color
+
+from electrum.gui.common_qt.util import TaskThread
+
+if TYPE_CHECKING:
+ from .main_window import ElectrumWindow
+ from .paytoedit import PayToEdit
+
+ from electrum.simple_config import SimpleConfig
+ from electrum.simple_config import ConfigVarWithConfig
+
+
+if platform.system() == 'Windows':
+ MONOSPACE_FONT = 'Lucida Console'
+elif platform.system() == 'Darwin':
+ MONOSPACE_FONT = 'Monaco'
+else:
+ MONOSPACE_FONT = 'monospace'
+
+
+_logger = get_logger(__name__)
+
+dialogs = []
+
+pr_icons = {
+ PR_UNKNOWN: "warning.png",
+ PR_UNPAID: "unpaid.png",
+ PR_PAID: "confirmed.png",
+ PR_EXPIRED: "expired.png",
+ PR_INFLIGHT: "unconfirmed.png",
+ PR_FAILED: "warning.png",
+ PR_ROUTING: "unconfirmed.png",
+ PR_UNCONFIRMED: "unconfirmed.png",
+ PR_BROADCASTING: "unconfirmed.png",
+ PR_BROADCAST: "unconfirmed.png",
+}
+
+
+# filter tx files in QFileDialog:
+TRANSACTION_FILE_EXTENSION_FILTER_ANY = "Transaction (*.txn *.psbt);;All files (*)"
+TRANSACTION_FILE_EXTENSION_FILTER_ONLY_PARTIAL_TX = "Partial Transaction (*.psbt)"
+TRANSACTION_FILE_EXTENSION_FILTER_ONLY_COMPLETE_TX = "Complete Transaction (*.txn)"
+TRANSACTION_FILE_EXTENSION_FILTER_SEPARATE = (f"{TRANSACTION_FILE_EXTENSION_FILTER_ONLY_PARTIAL_TX};;"
+ f"{TRANSACTION_FILE_EXTENSION_FILTER_ONLY_COMPLETE_TX};;"
+ f"All files (*)")
+
+
+class EnterButton(QPushButton):
+ def __init__(self, text, func):
+ QPushButton.__init__(self, text)
+ self.func = func
+ self.clicked.connect(func)
+ self._orig_text = text
+
+ def keyPressEvent(self, e):
+ if e.key() in [Qt.Key.Key_Return, Qt.Key.Key_Enter]:
+ self.func()
+
+ def restore_original_text(self):
+ self.setText(self._orig_text)
+
+
+class ThreadedButton(QPushButton):
+ def __init__(self, text, task, on_success=None, on_error=None):
+ QPushButton.__init__(self, text)
+ self.task = task
+ self.on_success = on_success
+ self.on_error = on_error
+ self.clicked.connect(self.run_task)
+
+ def run_task(self):
+ self.setEnabled(False)
+ self.thread = TaskThread(self)
+ self.thread.add(self.task, self.on_success, self.done, self.on_error)
+
+ def done(self):
+ self.setEnabled(True)
+ self.thread.stop()
+
+
+class WWLabel(QLabel):
+ """Word-wrapping label"""
+ def __init__(self, text="", parent=None):
+ QLabel.__init__(self, text, parent)
+ self.setWordWrap(True)
+ self.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
+
+
+class RichLabel(WWLabel):
+ """Word-wrapping label with link activation"""
+ def __init__(self, text='', parent=None):
+ WWLabel.__init__(self, text, parent)
+ self.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
+ self.setOpenExternalLinks(True)
+
+
+class AmountLabel(QLabel):
+ def __init__(self, *args, **kwargs):
+ QLabel.__init__(self, *args, **kwargs)
+ self.setFont(QFont(MONOSPACE_FONT))
+ self.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
+
+
+class Spinner(QLabel):
+ def __init__(self, *args, **kwargs):
+ QLabel.__init__(self, *args, **kwargs)
+ self.spinner = QMovie(icon_path('spinner.gif'))
+ self.spinner.setScaledSize(QSize(20, 20))
+ self.spinner.frameChanged.connect(lambda: self.setPixmap(self.spinner.currentPixmap()))
+ self.setVisible(False)
+
+ def setVisible(self, visible):
+ if visible:
+ self.spinner.start()
+ else:
+ self.spinner.stop()
+ super().setVisible(visible)
+
+
+class HelpMixin:
+ def __init__(self, help_text: str, *, help_title: str = None):
+ assert isinstance(self, QWidget), "HelpMixin must be a QWidget instance!"
+ self.help_text = help_text
+ self._help_title = help_title or _('Help')
+ if isinstance(self, QLabel):
+ self.setTextInteractionFlags(
+ (self.textInteractionFlags() | Qt.TextInteractionFlag.TextSelectableByMouse)
+ & ~Qt.TextInteractionFlag.TextSelectableByKeyboard)
+
+ def show_help(self):
+ custom_message_box(
+ icon=QMessageBox.Icon.Information,
+ parent=self,
+ title=self._help_title,
+ text=self.help_text,
+ rich_text=True,
+ )
+
+
+class HelpLabel(HelpMixin, QLabel):
+
+ def __init__(self, text: str, help_text: str):
+ QLabel.__init__(self, text)
+ HelpMixin.__init__(self, help_text)
+ self.app = QCoreApplication.instance()
+ self.font = self.font()
+
+ @classmethod
+ def from_configvar(cls, cv: 'ConfigVarWithConfig') -> 'HelpLabel':
+ return HelpLabel(cv.get_short_desc() + ':', cv.get_long_desc())
+
+ def mouseReleaseEvent(self, x):
+ self.show_help()
+
+ def enterEvent(self, event):
+ self.font.setUnderline(True)
+ self.setFont(self.font)
+ self.app.setOverrideCursor(QCursor(Qt.CursorShape.PointingHandCursor))
+ return QLabel.enterEvent(self, event)
+
+ def leaveEvent(self, event):
+ self.font.setUnderline(False)
+ self.setFont(self.font)
+ self.app.setOverrideCursor(QCursor(Qt.CursorShape.ArrowCursor))
+ return QLabel.leaveEvent(self, event)
+
+
+class HelpButton(HelpMixin, QToolButton):
+ def __init__(self, text: str):
+ QToolButton.__init__(self)
+ HelpMixin.__init__(self, text)
+ self.setText('?')
+ self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
+ self.setFixedWidth(round(2.2 * char_width_in_lineedit()))
+ self.clicked.connect(self.show_help)
+
+
+class InfoButton(HelpMixin, QPushButton):
+ def __init__(self, text: str):
+ QPushButton.__init__(self, _('Info'))
+ HelpMixin.__init__(self, text, help_title=_('Info'))
+ self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
+ self.setFixedWidth(6 * char_width_in_lineedit())
+ self.clicked.connect(self.show_help)
+
+
+class Buttons(QHBoxLayout):
+ def __init__(self, *buttons):
+ QHBoxLayout.__init__(self)
+ self.addStretch(1)
+ for b in buttons:
+ if b is None:
+ continue
+ self.addWidget(b)
+
+
+class CloseButton(QPushButton):
+ def __init__(self, dialog):
+ QPushButton.__init__(self, _("Close"))
+ self.clicked.connect(dialog.close)
+ self.setDefault(True)
+
+
+class CopyButton(QPushButton):
+ def __init__(self, text_getter, app):
+ QPushButton.__init__(self, _("Copy"))
+ self.clicked.connect(lambda: app.clipboard().setText(text_getter()))
+
+
+class CopyCloseButton(QPushButton):
+ def __init__(self, text_getter, app, dialog):
+ QPushButton.__init__(self, _("Copy and Close"))
+ self.clicked.connect(lambda: app.clipboard().setText(text_getter()))
+ self.clicked.connect(dialog.close)
+ self.setDefault(True)
+
+
+class OkButton(QPushButton):
+ def __init__(self, dialog, label=None):
+ QPushButton.__init__(self, label or _("OK"))
+ self.clicked.connect(dialog.accept)
+ self.setDefault(True)
+
+
+class CancelButton(QPushButton):
+ def __init__(self, dialog, label=None):
+ QPushButton.__init__(self, label or _("Cancel"))
+ self.clicked.connect(dialog.reject)
+
+
+class MessageBoxMixin(object):
+ def top_level_window_recurse(self, window=None, test_func=None):
+ window = window or self
+ classes = (WindowModalDialog, QMessageBox)
+ if test_func is None:
+ test_func = lambda x: True
+ for n, child in enumerate(window.children()):
+ # Test for visibility as old closed dialogs may not be GC-ed.
+ # Only accept children that confirm to test_func.
+ if isinstance(child, classes) and child.isVisible() \
+ and test_func(child):
+ return self.top_level_window_recurse(child, test_func=test_func)
+ return window
+
+ def top_level_window(self, test_func=None):
+ return self.top_level_window_recurse(test_func)
+
+ def question(self, msg, parent=None, title=None, icon=None, **kwargs) -> bool:
+ yes, no = QMessageBox.StandardButton.Yes, QMessageBox.StandardButton.No
+ return yes == self.msg_box(icon=icon or QMessageBox.Icon.Question,
+ parent=parent,
+ title=title or '',
+ text=msg,
+ buttons=yes | no,
+ defaultButton=no,
+ **kwargs)
+
+ def show_warning(self, msg, parent=None, title=None, **kwargs):
+ return self.msg_box(QMessageBox.Icon.Warning, parent,
+ title or _('Warning'), msg, **kwargs)
+
+ def show_error(self, msg, parent=None, **kwargs):
+ return self.msg_box(QMessageBox.Icon.Warning, parent,
+ _('Error'), msg, **kwargs)
+
+ def show_critical(self, msg, parent=None, title=None, **kwargs):
+ return self.msg_box(QMessageBox.Icon.Critical, parent,
+ title or _('Critical Error'), msg, **kwargs)
+
+ def show_message(self, msg, parent=None, title=None, icon=QMessageBox.Icon.Information, **kwargs):
+ return self.msg_box(icon, parent, title or _('Information'), msg, **kwargs)
+
+ def msg_box(
+ self,
+ icon: Union[QMessageBox.Icon, QPixmap],
+ parent: QWidget,
+ title: str,
+ text: str,
+ *,
+ buttons: Union[QMessageBox.StandardButton,
+ List[Union[QMessageBox.StandardButton, Tuple[QAbstractButton, QMessageBox.ButtonRole, int]]]] = QMessageBox.StandardButton.Ok,
+ defaultButton: QMessageBox.StandardButton = QMessageBox.StandardButton.NoButton,
+ rich_text: bool = False,
+ checkbox: Optional[bool] = None
+ ):
+ parent = parent or self.top_level_window()
+ return custom_message_box(
+ icon=icon, parent=parent, title=title, text=text, buttons=buttons, defaultButton=defaultButton,
+ rich_text=rich_text, checkbox=checkbox
+ )
+
+ def query_choice(
+ self,
+ msg: Optional[str],
+ choices: Sequence['ChoiceItem'],
+ *,
+ title: Optional[str] = None,
+ default_key: Optional[Any] = None,
+ ) -> Optional[Any]:
+ """Returns ChoiceItem.key (for selected item), or None if the user cancels the dialog.
+
+ Needed by QtHandler for hardware wallets.
+ """
+ if title is None:
+ title = _('Question')
+ dialog = WindowModalDialog(self.top_level_window(), title=title)
+ dialog.setMinimumWidth(400)
+ choice_widget = ChoiceWidget(message=msg, choices=choices, default_key=default_key)
+ vbox = QVBoxLayout(dialog)
+ vbox.addWidget(choice_widget)
+ cancel_button = CancelButton(dialog)
+ vbox.addLayout(Buttons(cancel_button, OkButton(dialog)))
+ cancel_button.setFocus()
+ if not dialog.exec():
+ return None
+ return choice_widget.selected_key
+
+ def password_dialog(self, msg=None, parent=None):
+ from .password_dialog import PasswordDialog
+ parent = parent or self
+ d = PasswordDialog(parent, msg)
+ return d.run()
+
+
+def custom_message_box(
+ *,
+ icon: Union[QMessageBox.Icon, QPixmap],
+ parent: QWidget,
+ title: str,
+ text: str,
+ buttons: Union[QMessageBox.StandardButton,
+ List[Union[QMessageBox.StandardButton, Tuple[QAbstractButton, QMessageBox.ButtonRole, int]]]] = QMessageBox.StandardButton.Ok,
+ defaultButton: QMessageBox.StandardButton = QMessageBox.StandardButton.NoButton,
+ rich_text: bool = False,
+ checkbox: Optional[bool] = None
+) -> int:
+ custom_buttons = []
+ standard_buttons = QMessageBox.StandardButton.NoButton
+ if buttons:
+ if not isinstance(buttons, list):
+ buttons = [buttons]
+ for button in buttons:
+ if isinstance(button, QMessageBox.StandardButton):
+ standard_buttons |= button
+ else:
+ custom_buttons.append(button)
+ if type(icon) is QPixmap:
+ d = QMessageBox(QMessageBox.Icon.Information, title, str(text), standard_buttons, parent)
+ d.setIconPixmap(icon)
+ else:
+ d = QMessageBox(icon, title, str(text), standard_buttons, parent)
+ for button, role, _ in custom_buttons:
+ d.addButton(button, role)
+ d.setWindowModality(Qt.WindowModality.WindowModal)
+ d.setDefaultButton(defaultButton)
+ if rich_text:
+ d.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse | Qt.TextInteractionFlag.LinksAccessibleByMouse)
+ # set AutoText instead of RichText
+ # AutoText lets Qt figure out whether to render as rich text.
+ # e.g. if text is actually plain text and uses "\n" newlines;
+ # and we set RichText here, newlines would be swallowed
+ d.setTextFormat(Qt.TextFormat.AutoText)
+ else:
+ d.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
+ d.setTextFormat(Qt.TextFormat.PlainText)
+ if checkbox is not None:
+ d.setCheckBox(checkbox)
+ result = d.exec()
+ for button, _, value in custom_buttons:
+ if button == d.clickedButton():
+ return value
+ return result
+
+
+class WindowModalDialog(QDialog, MessageBoxMixin):
+ '''Handy wrapper; window modal dialogs are better for our multi-window
+ daemon model as other wallet windows can still be accessed.'''
+ def __init__(self, parent, title=None):
+ QDialog.__init__(self, parent)
+ self.setWindowModality(Qt.WindowModality.WindowModal)
+ if title:
+ self.setWindowTitle(title)
+
+
+class WaitingDialog(WindowModalDialog):
+ '''Shows a please wait dialog whilst running a task. It is not
+ necessary to maintain a reference to this dialog.'''
+ def __init__(self, parent: QWidget, message: str, task, on_success=None, on_error=None, on_cancel=None):
+ assert parent
+ if isinstance(parent, MessageBoxMixin):
+ parent = parent.top_level_window()
+ WindowModalDialog.__init__(self, parent, _("Please wait"))
+ self.message_label = QLabel(message)
+ vbox = QVBoxLayout(self)
+ vbox.addWidget(self.message_label)
+ if on_cancel:
+ self.cancel_button = CancelButton(self)
+ self.cancel_button.clicked.connect(on_cancel)
+ vbox.addLayout(Buttons(self.cancel_button))
+ self.accepted.connect(self.on_accepted)
+ self.show()
+ self.thread = TaskThread(self)
+ self.thread.finished.connect(self.deleteLater) # see #3956
+ self.thread.add(task, on_success, self.accept, on_error)
+
+ def wait(self):
+ self.thread.wait()
+
+ def on_accepted(self):
+ self.thread.stop()
+
+ def update(self, msg):
+ print(msg)
+ self.message_label.setText(msg)
+
+
+class RunCoroutineDialog(WaitingDialog):
+
+ def __init__(self, parent: QWidget, message: str, coroutine):
+ from electrum import util
+ import asyncio
+ import concurrent.futures
+ loop = util.get_asyncio_loop()
+ assert util.get_running_loop() != loop, 'must not be called from asyncio thread'
+ self._exception = None
+ self._result = None
+ self._future = asyncio.run_coroutine_threadsafe(coroutine, loop)
+ def task():
+ try:
+ self._result = self._future.result()
+ except concurrent.futures.CancelledError:
+ self._exception = UserCancelled
+ except Exception as e:
+ self._exception = e
+ WaitingDialog.__init__(self, parent, message, task, on_cancel=self._future.cancel)
+
+ def run(self):
+ self.exec()
+ if self._exception:
+ raise self._exception
+ else:
+ return self._result
+
+
+def line_dialog(parent, title, label, ok_label, default=None):
+ dialog = WindowModalDialog(parent, title)
+ dialog.setMinimumWidth(500)
+ l = QVBoxLayout()
+ dialog.setLayout(l)
+ l.addWidget(QLabel(label))
+ txt = QLineEdit()
+ if default:
+ txt.setText(default)
+ l.addWidget(txt)
+ l.addLayout(Buttons(CancelButton(dialog), OkButton(dialog, ok_label)))
+ if dialog.exec():
+ return txt.text()
+
+
+def text_dialog(
+ *,
+ parent,
+ title,
+ header_layout,
+ ok_label,
+ default=None,
+ allow_multi=False,
+ config: 'SimpleConfig',
+):
+ from .qrtextedit import ScanQRTextEdit
+ dialog = WindowModalDialog(parent, title)
+ dialog.setMinimumWidth(600)
+ l = QVBoxLayout()
+ dialog.setLayout(l)
+ if isinstance(header_layout, str):
+ l.addWidget(QLabel(header_layout))
+ else:
+ l.addLayout(header_layout)
+ txt = ScanQRTextEdit(allow_multi=allow_multi, config=config)
+ if default:
+ txt.setText(default)
+ l.addWidget(txt)
+ l.addLayout(Buttons(CancelButton(dialog), OkButton(dialog, ok_label)))
+ if dialog.exec():
+ return txt.toPlainText()
+
+
+class ChoiceWidget(QWidget):
+ """Renders a list of ChoiceItems as a radiobuttons group.
+ Callers can pre-select an item by key, through the 'default_key' parameter.
+ The selected item is made available by index (selected_index),
+ by key (selected_key) and by Choice (selected_item).
+ """
+
+ itemSelected = pyqtSignal([int], arguments=['index'])
+
+ def __init__(
+ self,
+ *,
+ message: Optional[str] = None,
+ choices: Sequence[ChoiceItem] = None,
+ default_key: Optional[Any] = None,
+ ):
+ QWidget.__init__(self)
+ vbox = QVBoxLayout()
+ self.setLayout(vbox)
+
+ if choices is None:
+ choices = []
+
+ self.selected_index = -1 # type: int
+ self.selected_item = None # type: Optional[ChoiceItem]
+ self.selected_key = None # type: Optional[Any]
+ self.choices = choices # type: Sequence[ChoiceItem]
+
+ if message and len(message) > 50:
+ vbox.addWidget(WWLabel(message))
+ message = ""
+ gb2 = QGroupBox(message)
+ vbox.addWidget(gb2)
+ vbox2 = QVBoxLayout()
+ gb2.setLayout(vbox2)
+ self.group = group = QButtonGroup()
+ assert isinstance(choices, list)
+ for i, c in enumerate(choices):
+ assert isinstance(c, ChoiceItem), f"{c=!r}"
+ button = QRadioButton(gb2)
+ button.setText(c.label)
+ vbox2.addWidget(button)
+ group.addButton(button)
+ group.setId(button, i)
+ if (i == 0 and default_key is None) or c.key == default_key:
+ self.selected_index = i
+ self.selected_item = c
+ self.selected_key = c.key
+ button.setChecked(True)
+ group.buttonClicked.connect(self.on_selected)
+
+ def on_selected(self, button):
+ self.selected_index = self.group.id(button)
+ self.selected_item = self.choices[self.selected_index]
+ self.selected_key = self.choices[self.selected_index].key
+ self.itemSelected.emit(self.selected_index)
+
+ def select(self, key):
+ for i, c in enumerate(self.choices):
+ if key == c.key:
+ self.group.button(i).click()
+
+
+class ResizableStackedWidget(QWidget):
+ """Simple alternative to QStackedWidget, as QStackedWidget always resizes to the largest
+ widget in the stack, leaving ugly scrollbars where they're not needed."""
+ def __init__(self, parent):
+ super().__init__(parent)
+ self.setLayout(QVBoxLayout())
+ self.widgets = []
+ self.current_index = -1
+
+ def sizeHint(self) -> QSize:
+ if not self.count() or not self.currentWidget():
+ return super().sizeHint()
+ return self.currentWidget().sizeHint()
+
+ def addWidget(self, widget: QWidget) -> int:
+ self.widgets.append(widget)
+ self.layout().addWidget(widget)
+ if len(self.widgets) == 1: # first widget?
+ self.current_index = 0
+ self.showCurrentWidget()
+ return len(self.widgets) - 1
+
+ def removeWidget(self, widget: QWidget):
+ i = self.widgets.index(widget)
+ self.widgets.remove(widget)
+ self.layout().removeWidget(widget)
+ if self.current_index >= i:
+ self.current_index -= 1
+ if self.current_index == self.count() - 1:
+ self.showCurrentWidget()
+
+ def setCurrentIndex(self, index: int):
+ assert isinstance(index, int)
+ assert 0 <= index < len(self.widgets), f'invalid widget index {index}'
+ self.current_index = index
+ self.showCurrentWidget()
+
+ def currentWidget(self) -> Optional[QWidget]:
+ if self.current_index < 0:
+ return None
+ return self.widgets[self.current_index]
+
+ def showCurrentWidget(self):
+ if not self.widgets:
+ return
+
+ for i, k in enumerate(self.widgets):
+ if i == self.current_index:
+ k.show()
+ else:
+ k.hide()
+
+ def count(self) -> int:
+ return len(self.widgets)
+
+
+class VLine(QFrame):
+ """Vertical line separator"""
+ def __init__(self):
+ super(VLine, self).__init__()
+ self.setFrameShape(QFrame.Shape.VLine)
+ self.setFrameShadow(QFrame.Shadow.Sunken)
+ self.setLineWidth(1)
+
+
+def address_field(addresses, *, btn_text: str = None):
+ if btn_text is None:
+ btn_text = _('Get wallet address')
+ hbox = QHBoxLayout()
+ address_e = QLineEdit()
+ if addresses and len(addresses) > 0:
+ address_e.setText(addresses[0])
+ else:
+ addresses = []
+
+ def func():
+ try:
+ i = addresses.index(str(address_e.text())) + 1
+ i = i % len(addresses)
+ address_e.setText(addresses[i])
+ except ValueError:
+ # the user might have changed address_e to an
+ # address not in the wallet (or to something that isn't an address)
+ if addresses and len(addresses) > 0:
+ address_e.setText(addresses[0])
+ button = QPushButton(btn_text)
+ button.clicked.connect(func)
+ hbox.addWidget(button)
+ hbox.addWidget(address_e)
+ return hbox, address_e
+
+
+def filename_field(parent, config, defaultname, select_msg):
+ vbox = QVBoxLayout()
+ vbox.addWidget(QLabel(_("Format")))
+ gb = QGroupBox("format", parent)
+ b1 = QRadioButton(gb)
+ b1.setText(_("CSV"))
+ b1.setChecked(True)
+ b2 = QRadioButton(gb)
+ b2.setText(_("json"))
+ vbox.addWidget(b1)
+ vbox.addWidget(b2)
+
+ hbox = QHBoxLayout()
+
+ directory = config.IO_DIRECTORY
+ path = os.path.join(directory, defaultname)
+ filename_e = QLineEdit()
+ filename_e.setText(path)
+
+ def func():
+ text = filename_e.text()
+ _filter = "*.csv" if defaultname.endswith(".csv") else "*.json" if defaultname.endswith(".json") else None
+ p = getSaveFileName(
+ parent=None,
+ title=select_msg,
+ filename=text,
+ filter=_filter,
+ config=config,
+ )
+ if p:
+ filename_e.setText(p)
+
+ button = QPushButton(_('File'))
+ button.clicked.connect(func)
+ hbox.addWidget(button)
+ hbox.addWidget(filename_e)
+ vbox.addLayout(hbox)
+
+ def set_csv(v):
+ text = filename_e.text()
+ text = text.replace(".json",".csv") if v else text.replace(".csv",".json")
+ filename_e.setText(text)
+
+ b1.clicked.connect(lambda: set_csv(True))
+ b2.clicked.connect(lambda: set_csv(False))
+
+ return vbox, filename_e, b1
+
+
+def get_icon_qrcode() -> QIcon:
+ name = "qrcode_white.png" if ColorScheme.dark_scheme else "qrcode.png"
+ return read_QIcon(name)
+
+
+def get_icon_camera() -> QIcon:
+ name = "camera_white.png" if ColorScheme.dark_scheme else "camera_dark.png"
+ return read_QIcon(name)
+
+
+def pubkey_to_q_icon(server_pubkey: str) -> QIcon:
+ color = QColor(*pubkey_to_rgb_color(server_pubkey))
+ color_pixmap = QPixmap(100, 100)
+ color_pixmap.fill(color)
+ return QIcon(color_pixmap)
+
+
+def add_input_actions_to_context_menu(gih: 'GenericInputHandler', m: QMenu) -> None:
+ if gih.on_qr_from_camera_input_btn:
+ m.addAction(get_icon_camera(), _("Read QR code with camera"), gih.on_qr_from_camera_input_btn)
+ if gih.on_qr_from_screenshot_input_btn:
+ m.addAction(read_QIcon("picture_in_picture.png"), _("Read QR code from screen"), gih.on_qr_from_screenshot_input_btn)
+ if gih.on_qr_from_file_input_btn:
+ m.addAction(read_QIcon("qr_file.png"), _("Read QR code from file"), gih.on_qr_from_file_input_btn)
+ if gih.on_input_file:
+ m.addAction(read_QIcon("file.png"), _("Read text from file"), gih.on_input_file)
+
+
+def scan_qr_from_screenshot() -> QrCodeResult:
+ from .qrreader import scan_qr_from_image
+ screenshots = [screen.grabWindow(0).toImage()
+ for screen in QApplication.instance().screens()]
+ if all(screen.allGray() for screen in screenshots):
+ raise UserFacingException(_("Failed to take screenshot."))
+ scanned_qr = None
+ for screenshot in screenshots:
+ try:
+ scan_result = scan_qr_from_image(screenshot)
+ except MissingQrDetectionLib as e:
+ raise UserFacingException(_("Unable to scan image.") + "\n" + repr(e))
+ if len(scan_result) > 0:
+ if (scanned_qr is not None) or len(scan_result) > 1:
+ raise UserFacingException(_("More than one QR code was found on the screen."))
+ scanned_qr = scan_result
+ if scanned_qr is None:
+ raise UserFacingException(_("No QR code was found on the screen."))
+ assert len(scanned_qr) == 1, f"{len(scanned_qr)=}, expected 1"
+ return scanned_qr[0]
+
+
+class GenericInputHandler:
+ on_qr_from_camera_input_btn: Callable[[], None] = None
+ on_qr_from_screenshot_input_btn: Callable[[], None] = None
+ on_qr_from_file_input_btn: Callable[[], None] = None
+ on_input_file: Callable[[], None] = None
+
+ def input_qr_from_camera(
+ self,
+ *,
+ config: 'SimpleConfig',
+ allow_multi: bool = False,
+ show_error: Callable[[str], None],
+ setText: Callable[[str], None] = None,
+ parent: QWidget = None,
+ ) -> None:
+ if setText is None:
+ setText = self.setText
+ def cb(success: bool, error: str, data: Optional[str]):
+ if not success:
+ if error:
+ show_error(error)
+ return
+ if not data:
+ data = ''
+ try:
+ if allow_multi:
+ text = self.text()
+ if data in text:
+ return
+ if text and not text.endswith('\n'):
+ text += '\n'
+ text += data
+ text += '\n'
+ setText(text)
+ else:
+ new_text = data
+ setText(new_text)
+ except Exception as e:
+ show_error(_('Invalid payment identifier in QR') + ':\n' + repr(e))
+
+ from .qrreader import scan_qrcode_from_camera
+ if parent is None:
+ parent = self if isinstance(self, QWidget) else None
+ scan_qrcode_from_camera(parent=parent, config=config, callback=cb)
+
+ def input_qr_from_screenshot(
+ self,
+ *,
+ allow_multi: bool = False,
+ show_error: Callable[[str], None],
+ setText: Callable[[str], None] = None,
+ ) -> None:
+ if setText is None:
+ setText = self.setText
+ try:
+ scanned_qr = scan_qr_from_screenshot()
+ except UserFacingException as e:
+ show_error(str(e))
+ return
+ data = scanned_qr.data
+ try:
+ if allow_multi:
+ text = self.text()
+ if data in text:
+ return
+ if text and not text.endswith('\n'):
+ text += '\n'
+ text += data
+ text += '\n'
+ setText(text)
+ else:
+ new_text = data
+ setText(new_text)
+ except Exception as e:
+ show_error(_('Invalid payment identifier in QR') + ':\n' + repr(e))
+
+ def input_file(
+ self,
+ *,
+ config: 'SimpleConfig',
+ show_error: Callable[[str], None],
+ setText: Callable[[str], None] = None,
+ ) -> None:
+ if setText is None:
+ setText = self.setText
+ fileName = getOpenFileName(
+ parent=None,
+ title='select file',
+ # trying to open non-text things like pdfs makes electrum freeze
+ filter="Text files (*.txt *.csv);;All files (*)",
+ config=config,
+ )
+ if not fileName:
+ return
+ try:
+ try:
+ with open(fileName, "r") as f:
+ data = f.read()
+ except UnicodeError as e:
+ with open(fileName, "rb") as f:
+ data = f.read()
+ data = data.hex()
+ except BaseException as e:
+ show_error(_('Error opening file') + ':\n' + repr(e))
+ else:
+ try:
+ setText(data)
+ except Exception as e:
+ show_error(_('Invalid payment identifier in file') + ':\n' + repr(e))
+
+ def input_qr_from_file(
+ self,
+ *,
+ allow_multi: bool = False,
+ config: 'SimpleConfig',
+ show_error: Callable[[str], None],
+ setText: Callable[[str], None] = None,
+ ):
+ from .qrreader import scan_qr_from_image
+ if setText is None:
+ setText = self.setText
+
+ file_name = getOpenFileName(
+ parent=None,
+ title=_("Select image file"),
+ config=config,
+ filter="Image files (*.png *.jpg *.jpeg *.bmp);;",
+ )
+ if not file_name:
+ return
+ image = QImage(file_name)
+ if image.isNull():
+ show_error(_("Failed to open image file."))
+ return
+ try:
+ scan_result: Sequence[QrCodeResult] = scan_qr_from_image(image)
+ except MissingQrDetectionLib as e:
+ show_error(_("Unable to scan image.") + "\n" + repr(e))
+ return
+ if len(scan_result) < 1:
+ show_error(_("No QR code was found in the image."))
+ return
+ if len(scan_result) > 1 and not allow_multi:
+ show_error(_("More than one QR code was found in the image."))
+ return
+
+ if len(scan_result) > 1:
+ result_text = "\n".join([r.data for r in scan_result])
+ else:
+ result_text = scan_result[0].data
+
+ try:
+ setText(result_text)
+ except Exception as e:
+ show_error(_("Couldn't set result") + ':\n' + repr(e))
+
+ def input_paste_from_clipboard(
+ self,
+ *,
+ setText: Callable[[str], None] = None,
+ ) -> None:
+ if setText is None:
+ setText = self.setText
+ app = QApplication.instance()
+ setText(app.clipboard().text())
+
+
+class OverlayControlMixin(GenericInputHandler):
+ STYLE_SHEET_COMMON = '''
+ QPushButton { border-width: 1px; padding: 0px; margin: 0px; }
+ '''
+
+ STYLE_SHEET_LIGHT = '''
+ QPushButton { border: 1px solid transparent; }
+ QPushButton:hover { border: 1px solid #3daee9; }
+ '''
+
+ def __init__(self, middle: bool = False):
+ GenericInputHandler.__init__(self)
+ assert isinstance(self, QWidget)
+ assert isinstance(self, OverlayControlMixin) # only here for type-hints in IDE
+ self.middle = middle
+ self.overlay_widget = QWidget(self)
+ style_sheet = self.STYLE_SHEET_COMMON
+ if not ColorScheme.dark_scheme:
+ style_sheet = style_sheet + self.STYLE_SHEET_LIGHT
+ self.overlay_widget.setStyleSheet(style_sheet)
+ self.overlay_layout = QHBoxLayout(self.overlay_widget)
+ self.overlay_layout.setContentsMargins(0, 0, 0, 0)
+ self.overlay_layout.setSpacing(1)
+ self._updateOverlayPos()
+
+ def resizeEvent(self, e):
+ super().resizeEvent(e)
+ self._updateOverlayPos()
+
+ def _updateOverlayPos(self):
+ frame_width = self.style().pixelMetric(QStyle.PixelMetric.PM_DefaultFrameWidth)
+ overlay_size = self.overlay_widget.sizeHint()
+ x = self.rect().right() - frame_width - overlay_size.width()
+ y = self.rect().bottom() - overlay_size.height()
+ middle = self.middle
+ if hasattr(self, 'document'):
+ # Keep the buttons centered if we have less than 2 lines in the editor
+ line_spacing = QFontMetrics(self.document().defaultFont()).lineSpacing()
+ if self.rect().height() < (line_spacing * 2):
+ middle = True
+ y = (y / 2) + frame_width if middle else y - frame_width
+ if hasattr(self, 'verticalScrollBar') and self.verticalScrollBar().isVisible():
+ scrollbar_width = self.style().pixelMetric(QStyle.PixelMetric.PM_ScrollBarExtent)
+ x -= scrollbar_width
+ self.overlay_widget.move(int(x), int(y))
+
+ def addWidget(self, widget: QWidget):
+ # The old code positioned the items the other way around, so we just insert at position 0 instead
+ self.overlay_layout.insertWidget(0, widget)
+
+ def addButton(self, icon: QIcon, on_click, tooltip: str) -> QPushButton:
+ button = QPushButton(self.overlay_widget)
+ button.setToolTip(tooltip)
+ button.setIcon(icon)
+ button.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
+ button.clicked.connect(on_click)
+ self.addWidget(button)
+ return button
+
+ def addCopyButton(self):
+ def on_copy():
+ app = QApplication.instance()
+ app.clipboard().setText(self.text())
+ QToolTip.showText(QCursor.pos(), _("Text copied to clipboard"), self)
+ self.addButton(read_QIcon("copy.png"), on_copy, _("Copy to clipboard"))
+
+ def addPasteButton(
+ self,
+ *,
+ setText: Callable[[str], None] = None,
+ ):
+ input_paste_from_clipboard = partial(
+ self.input_paste_from_clipboard,
+ setText=setText,
+ )
+ self.addButton(read_QIcon("copy.png"), input_paste_from_clipboard, _("Paste from clipboard"))
+
+ def add_qr_show_button(self, *, config: 'SimpleConfig', title: Optional[str] = None):
+ if title is None:
+ title = _("QR code")
+
+ def qr_show():
+ from .qrcodewidget import QRDialog
+ try:
+ s = str(self.text())
+ except Exception:
+ s = self.text()
+ if not s:
+ return
+ QRDialog(
+ data=s,
+ parent=self,
+ title=title,
+ config=config,
+ ).exec()
+
+ self.addButton(get_icon_qrcode(), qr_show, _("Show as QR code"))
+ # side-effect: we export this method:
+ self.on_qr_show_btn = qr_show
+
+ def add_qr_input_from_camera_button(
+ self,
+ *,
+ config: 'SimpleConfig',
+ allow_multi: bool = False,
+ show_error: Callable[[str], None],
+ setText: Callable[[str], None] = None,
+ ):
+ input_qr_from_camera = partial(
+ self.input_qr_from_camera,
+ config=config,
+ allow_multi=allow_multi,
+ show_error=show_error,
+ setText=setText,
+ )
+ self.addButton(get_icon_camera(), input_qr_from_camera, _("Read QR code with camera"))
+ # side-effect: we export these methods:
+ self.on_qr_from_camera_input_btn = input_qr_from_camera
+
+ def add_file_input_button(
+ self,
+ *,
+ config: 'SimpleConfig',
+ show_error: Callable[[str], None],
+ setText: Callable[[str], None] = None,
+ ) -> None:
+ input_file = partial(
+ self.input_file,
+ config=config,
+ show_error=show_error,
+ setText=setText,
+ )
+ self.addButton(read_QIcon("file.png"), input_file, _("Read file"))
+
+ def add_menu_button(
+ self,
+ *,
+ options: Sequence[Tuple[Optional[Union[str, QIcon]], str, Callable[[], None]]], # list of (icon, text, cb)
+ icon: Optional[QIcon] = None,
+ tooltip: Optional[str] = None,
+ ):
+ if icon is None:
+ icon_name = "menu_vertical_white.png" if ColorScheme.dark_scheme else "menu_vertical.png"
+ icon = read_QIcon(icon_name)
+ if tooltip is None:
+ tooltip = _("Other options")
+ btn = self.addButton(icon, lambda: None, tooltip)
+ menu = QMenu()
+ for opt_icon, opt_text, opt_cb in options:
+ if opt_icon is None:
+ menu.addAction(opt_text, opt_cb)
+ else:
+ opt_icon = read_QIcon(opt_icon) if isinstance(opt_icon, str) else opt_icon
+ menu.addAction(opt_icon, opt_text, opt_cb)
+ btn.setMenu(menu)
+
+
+class ButtonsLineEdit(OverlayControlMixin, QLineEdit):
+ def __init__(self, text=None):
+ QLineEdit.__init__(self, text)
+ OverlayControlMixin.__init__(self, middle=True)
+
+
+class ShowQRLineEdit(ButtonsLineEdit):
+ """ read-only line with qr and copy buttons """
+ def __init__(self, text: str, config, title=None):
+ ButtonsLineEdit.__init__(self, text)
+ self.setReadOnly(True)
+ self.setFont(QFont(MONOSPACE_FONT))
+ self.add_qr_show_button(config=config, title=title)
+ self.addCopyButton()
+
+
+class ButtonsTextEdit(OverlayControlMixin, QPlainTextEdit):
+ def __init__(self, text=None):
+ QPlainTextEdit.__init__(self, text)
+ OverlayControlMixin.__init__(self)
+ self.setText = self.setPlainText
+ self.text = self.toPlainText
+
+
+class PasswordLineEdit(QLineEdit):
+ def __init__(self, *args, **kwargs):
+ QLineEdit.__init__(self, *args, **kwargs)
+ self.setEchoMode(QLineEdit.EchoMode.Password)
+
+ def clear(self):
+ # Try to actually overwrite the memory.
+ # This is really just a best-effort thing...
+ self.setText(len(self.text()) * " ")
+ super().clear()
+
+
+class ColorSchemeItem:
+ def __init__(self, fg_color, bg_color):
+ self.colors = (fg_color, bg_color)
+
+ def _get_color(self, background):
+ return self.colors[(int(background) + int(ColorScheme.dark_scheme)) % 2]
+
+ def as_stylesheet(self, background=False):
+ css_prefix = "background-" if background else ""
+ color = self._get_color(background)
+ return "QWidget {{ {}color:{}; }}".format(css_prefix, color)
+
+ def as_color(self, background=False):
+ color = self._get_color(background)
+ return QColor(color)
+
+
+class ColorScheme:
+ dark_scheme = False
+
+ GREEN = ColorSchemeItem("#117c11", "#8af296")
+ YELLOW = ColorSchemeItem("#897b2a", "#ffff00")
+ RED = ColorSchemeItem("#7c1111", "#f18c8c")
+ BLUE = ColorSchemeItem("#123b7c", "#8cb3f2")
+ LIGHTBLUE = ColorSchemeItem("black", "#d0f0ff")
+ DEFAULT = ColorSchemeItem("black", "white")
+ GRAY = ColorSchemeItem("gray", "gray")
+ ORANGE = ColorSchemeItem("#ff9b45", "#ff9b45")
+
+ @staticmethod
+ def has_dark_background(widget):
+ brightness = sum(widget.palette().color(QPalette.ColorRole.Window).getRgb()[0:3])
+ return brightness < (255*3/2)
+
+ @staticmethod
+ def update_from_widget(widget, force_dark=False):
+ ColorScheme.dark_scheme = bool(force_dark or ColorScheme.has_dark_background(widget))
+
+
+class AcceptFileDragDrop:
+ def __init__(self, file_type=""):
+ assert isinstance(self, QWidget)
+ self.setAcceptDrops(True)
+ self.file_type = file_type
+
+ def validateEvent(self, event):
+ if not event.mimeData().hasUrls():
+ event.ignore()
+ return False
+ for url in event.mimeData().urls():
+ if not url.toLocalFile().endswith(self.file_type):
+ event.ignore()
+ return False
+ event.accept()
+ return True
+
+ def dragEnterEvent(self, event):
+ self.validateEvent(event)
+
+ def dragMoveEvent(self, event):
+ if self.validateEvent(event):
+ event.setDropAction(Qt.DropAction.CopyAction)
+
+ def dropEvent(self, event):
+ if self.validateEvent(event):
+ for url in event.mimeData().urls():
+ self.onFileAdded(url.toLocalFile())
+
+ def onFileAdded(self, fn):
+ raise NotImplementedError()
+
+
+def import_meta_gui(electrum_window: 'ElectrumWindow', title, importer, on_success):
+ filter_ = "JSON (*.json);;All files (*)"
+ filename = getOpenFileName(
+ parent=electrum_window,
+ title=_("Open {} file").format(title),
+ filter=filter_,
+ config=electrum_window.config,
+ )
+ if not filename:
+ return
+ try:
+ importer(filename)
+ except FileImportFailed as e:
+ electrum_window.show_critical(str(e))
+ else:
+ electrum_window.show_message(_("Your {} were successfully imported").format(title))
+ on_success()
+
+
+def export_meta_gui(electrum_window: 'ElectrumWindow', title, exporter):
+ filter_ = "JSON (*.json);;All files (*)"
+ filename = getSaveFileName(
+ parent=electrum_window,
+ title=_("Select file to save your {}").format(title),
+ filename='electrum_{}.json'.format(title),
+ filter=filter_,
+ config=electrum_window.config,
+ )
+ if not filename:
+ return
+ try:
+ exporter(filename)
+ except FileExportFailed as e:
+ electrum_window.show_critical(str(e))
+ else:
+ electrum_window.show_message(_("Your {0} were exported to '{1}'")
+ .format(title, str(filename)))
+
+
+def getOpenFileName(*, parent, title, filter="", config: 'SimpleConfig') -> Optional[str]:
+ """Custom wrapper for getOpenFileName that remembers the path selected by the user."""
+ directory = config.IO_DIRECTORY
+ fileName, __ = QFileDialog.getOpenFileName(parent, title, directory, filter)
+ if fileName and directory != os.path.dirname(fileName):
+ config.IO_DIRECTORY = os.path.dirname(fileName)
+ return fileName
+
+
+def getSaveFileName(
+ *,
+ parent,
+ title,
+ filename,
+ filter="",
+ default_extension: str = None,
+ default_filter: str = None,
+ config: 'SimpleConfig',
+) -> Optional[str]:
+ """Custom wrapper for getSaveFileName that remembers the path selected by the user."""
+ directory = config.IO_DIRECTORY
+ path = os.path.join(directory, filename)
+
+ file_dialog = QFileDialog(parent, title, path, filter)
+ file_dialog.setAcceptMode(QFileDialog.AcceptMode.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.DialogCode.Accepted:
+ return None
+
+ selected_path = file_dialog.selectedFiles()[0]
+ if selected_path and directory != os.path.dirname(selected_path):
+ config.IO_DIRECTORY = os.path.dirname(selected_path)
+ return selected_path
+
+
+def icon_path(icon_basename: str):
+ return resource_path('gui', 'icons', icon_basename)
+
+
+def internal_plugin_icon_path(plugin_name, icon_basename: str):
+ return resource_path('plugins', plugin_name, icon_basename)
+
+
+@lru_cache(maxsize=1000)
+def read_QIcon(icon_basename: str) -> QIcon:
+ return QIcon(icon_path(icon_basename))
+
+
+def read_QPixmap_from_bytes(b: bytes) -> QPixmap:
+ qp = QPixmap()
+ qp.loadFromData(b)
+ return qp
+
+
+def read_QIcon_from_bytes(b: bytes) -> QIcon:
+ qp = read_QPixmap_from_bytes(b)
+ return QIcon(qp)
+
+
+class IconLabel(QWidget):
+ HorizontalSpacing = 2
+ def __init__(self, *, text='', final_stretch=True, reverse=False, hide_if_empty=False):
+ super(QWidget, self).__init__()
+ self.hide_if_empty = hide_if_empty
+ size = max(16, font_height())
+ self.icon_size = QSize(size, size)
+ layout = QHBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ self.setLayout(layout)
+ self.icon = QLabel()
+ self.label = QLabel(text)
+ self.label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
+ layout.addWidget(self.icon if reverse else self.label)
+ layout.addSpacing(self.HorizontalSpacing)
+ layout.addWidget(self.label if reverse else self.icon)
+ if final_stretch:
+ layout.addStretch()
+ self.setText(text)
+
+ def setText(self, text):
+ self.label.setText(text)
+ if self.hide_if_empty:
+ self.setVisible(bool(text))
+
+ def setIcon(self, icon):
+ self.icon.setPixmap(icon.pixmap(self.icon_size))
+ self.icon.repaint() # macOS hack for #6269
+
+
+def char_width_in_lineedit() -> int:
+ char_width = QFontMetrics(QLineEdit().font()).averageCharWidth()
+ # 'averageCharWidth' seems to underestimate on Windows, hence 'max()'
+ return max(9, char_width)
+
+
+def font_height(widget: QWidget = None) -> int:
+ if widget is None:
+ widget = QLabel()
+ return QFontMetrics(widget.font()).height()
+
+
+def webopen(url: str):
+ if sys.platform == 'linux' and os.environ.get('APPIMAGE'):
+ # When on Linux webbrowser.open can fail in AppImage because it can't find the correct libdbus.
+ # We just fork the process and unset LD_LIBRARY_PATH before opening the URL.
+ # See #5425
+ if os.fork() == 0:
+ del os.environ['LD_LIBRARY_PATH']
+ webbrowser.open(url)
+ os._exit(0)
+ else:
+ webbrowser.open(url)
+
+
+class FixedAspectRatioLayout(QLayout):
+ def __init__(self, parent: QWidget = None, aspect_ratio: float = 1.0):
+ super().__init__(parent)
+ self.aspect_ratio = aspect_ratio
+ self.items: List[QLayoutItem] = []
+
+ def set_aspect_ratio(self, aspect_ratio: float = 1.0):
+ self.aspect_ratio = aspect_ratio
+ self.update()
+
+ def addItem(self, item: QLayoutItem):
+ self.items.append(item)
+
+ def count(self) -> int:
+ return len(self.items)
+
+ def itemAt(self, index: int) -> QLayoutItem:
+ if index >= len(self.items):
+ return None
+ return self.items[index]
+
+ def takeAt(self, index: int) -> QLayoutItem:
+ if index >= len(self.items):
+ return None
+ return self.items.pop(index)
+
+ def _get_contents_margins_size(self) -> QSize:
+ margins = self.contentsMargins()
+ return QSize(margins.left() + margins.right(), margins.top() + margins.bottom())
+
+ def setGeometry(self, rect: QRect):
+ super().setGeometry(rect)
+ if not self.items:
+ return
+
+ contents = self.contentsRect()
+ if contents.height() > 0:
+ c_aratio = contents.width() / contents.height()
+ else:
+ c_aratio = 1
+ s_aratio = self.aspect_ratio
+ item_rect = QRect(QPoint(0, 0), QSize(
+ contents.width() if c_aratio < s_aratio else int(contents.height() * s_aratio),
+ contents.height() if c_aratio > s_aratio else int(contents.width() / s_aratio)
+ ))
+
+ content_margins = self.contentsMargins()
+ free_space = contents.size() - item_rect.size()
+
+ for item in self.items:
+ if free_space.width() > 0 and not item.alignment() & Qt.AlignmentFlag.AlignLeft:
+ if item.alignment() & Qt.AlignmentFlag.AlignRight:
+ item_rect.moveRight(contents.width() + content_margins.right())
+ else:
+ item_rect.moveLeft(content_margins.left() + (free_space.width() // 2))
+ else:
+ item_rect.moveLeft(content_margins.left())
+
+ if free_space.height() > 0 and not item.alignment() & Qt.AlignmentFlag.AlignTop:
+ if item.alignment() & Qt.AlignmentFlag.AlignBottom:
+ item_rect.moveBottom(contents.height() + content_margins.bottom())
+ else:
+ item_rect.moveTop(content_margins.top() + (free_space.height() // 2))
+ else:
+ item_rect.moveTop(content_margins.top())
+
+ item.widget().setGeometry(item_rect)
+
+ def sizeHint(self) -> QSize:
+ result = QSize()
+ for item in self.items:
+ result = result.expandedTo(item.sizeHint())
+ return self._get_contents_margins_size() + result
+
+ def minimumSize(self) -> QSize:
+ result = QSize()
+ for item in self.items:
+ result = result.expandedTo(item.minimumSize())
+ return self._get_contents_margins_size() + result
+
+ def expandingDirections(self) -> Qt.Orientation:
+ return Qt.Orientation.Horizontal | Qt.Orientation.Vertical
+
+
+def QColorLerp(a: QColor, b: QColor, t: float):
+ """
+ Blends two QColors. t=0 returns a. t=1 returns b. t=0.5 returns evenly mixed.
+ """
+ t = max(min(t, 1.0), 0.0)
+ i_t = 1.0 - t
+ return QColor(
+ int((a.red() * i_t) + (b.red() * t)),
+ int((a.green() * i_t) + (b.green() * t)),
+ int((a.blue() * i_t) + (b.blue() * t)),
+ int((a.alpha() * i_t) + (b.alpha() * t)),
+ )
+
+
+class ImageGraphicsEffect(QObject):
+ """
+ Applies a QGraphicsEffect to a QImage
+ """
+
+ def __init__(self, parent: QObject, effect: QGraphicsEffect):
+ super().__init__(parent)
+ assert effect, 'effect must be set'
+ self.effect = effect
+ self.graphics_scene = QGraphicsScene()
+ self.graphics_item = QGraphicsPixmapItem()
+ self.graphics_item.setGraphicsEffect(effect)
+ self.graphics_scene.addItem(self.graphics_item)
+
+ def apply(self, image: QImage):
+ assert image, 'image must be set'
+ result = QImage(image.size(), QImage.Format.Format_ARGB32)
+ result.fill(Qt.GlobalColor.transparent)
+ painter = QPainter(result)
+ self.graphics_item.setPixmap(QPixmap.fromImage(image))
+ self.graphics_scene.render(painter)
+ self.graphics_item.setPixmap(QPixmap())
+ return result
+
+
+def insert_spaces(text: str, every_chars: int) -> str:
+ '''Insert spaces at every Nth character to allow for WordWrap'''
+ return ' '.join(text[i:i+every_chars] for i in range(0, len(text), every_chars))
+
+
+def set_windows_os_screenshot_protection_drm_flag(window: QWidget) -> None:
+ """
+ sets the windows WDA_MONITOR flag on the window so windows prevents capturing
+ screenshots and microsoft recall will not be able to record the window
+ https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setwindowdisplayaffinity
+ """
+ if sys.platform not in ('win32', 'windows'):
+ return
+ try:
+ window_id = int(window.winId())
+ WDA_MONITOR = 0x01
+ ctypes.windll.user32.SetWindowDisplayAffinity(window_id, WDA_MONITOR)
+ except Exception:
+ _logger.exception(f"failed to set windows screenshot protection flag")
+
+
+def debug_widget_layouts(gui_element: QObject):
+ """Draw red borders around all widgets of given QObject for debugging.
+ E.g. add util.debug_widget_layouts(self) at the end of TxEditor.__init__
+ """
+ assert isinstance(gui_element, QObject) and hasattr(gui_element, 'findChildren')
+ def set_border(widget):
+ if widget is not None:
+ widget.setStyleSheet(widget.styleSheet() + " * { border: 1px solid red; }")
+
+ # Apply to all child widgets recursively
+ for widget in gui_element.findChildren(QWidget):
+ set_border(widget)
+
+
+class _ABCQObjectMeta(type(QObject), ABCMeta): pass
+class _ABCQWidgetMeta(type(QWidget), ABCMeta): pass
+class AbstractQObject(QObject, ABC, metaclass=_ABCQObjectMeta): pass
+class AbstractQWidget(QWidget, ABC, metaclass=_ABCQWidgetMeta): pass
+
+
+if __name__ == "__main__":
+ app = QApplication([])
+ t = WaitingDialog(None, 'testing ...', lambda: [time.sleep(1)], lambda x: QMessageBox.information(None, 'done', "done"))
+ t.start()
+ app.exec()
diff --git a/electrum/gui/qt/utxo_dialog.py b/electrum/gui/qt/utxo_dialog.py
new file mode 100644
index 000000000000..f63837e3dc5a
--- /dev/null
+++ b/electrum/gui/qt/utxo_dialog.py
@@ -0,0 +1,158 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2023 The Electrum Developers
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from typing import TYPE_CHECKING
+import copy
+
+from PyQt6.QtCore import Qt, QUrl
+from PyQt6.QtGui import QTextCharFormat, QFont
+from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel
+
+from electrum.i18n import _
+
+from .util import WindowModalDialog, ColorScheme, Buttons, CloseButton, MONOSPACE_FONT, WWLabel
+from .transaction_dialog import TxOutputColoring, QTextBrowserWithDefaultSize
+
+if TYPE_CHECKING:
+ from electrum.transaction import PartialTxInput
+ from .main_window import ElectrumWindow
+
+
+class UTXODialog(WindowModalDialog):
+
+ def __init__(self, window: 'ElectrumWindow', utxo: 'PartialTxInput'):
+ WindowModalDialog.__init__(self, window, _("Coin Privacy Analysis"))
+ self.main_window = window
+ self.config = window.config
+ self.wallet = window.wallet
+ self.utxo = utxo
+
+ self.parents_list = QTextBrowserWithDefaultSize(800, 400)
+ self.parents_list.setOpenLinks(False) # disable automatic link opening
+ self.parents_list.anchorClicked.connect(self.open_tx) # send links to our handler
+ self.parents_list.setFont(QFont(MONOSPACE_FONT))
+ self.parents_list.setReadOnly(True)
+ self.parents_list.setTextInteractionFlags(
+ self.parents_list.textInteractionFlags() |
+ Qt.TextInteractionFlag.LinksAccessibleByMouse |
+ Qt.TextInteractionFlag.LinksAccessibleByKeyboard
+ )
+ self.txo_color_parent = TxOutputColoring(
+ legend=_("Direct parent"), color=ColorScheme.BLUE, tooltip=_("Direct parent"))
+ self.txo_color_uncle = TxOutputColoring(
+ legend=_("Address reuse"), color=ColorScheme.RED, tooltip=_("Address reuse"))
+
+ vbox = QVBoxLayout()
+ vbox.addWidget(QLabel(_("Output point") + ": " + str(self.utxo.short_id)))
+ vbox.addWidget(QLabel(_("Amount") + ": " + self.main_window.format_amount_and_units(self.utxo.value_sats())))
+ self.stats_label = WWLabel()
+ vbox.addWidget(self.stats_label)
+ vbox.addWidget(self.parents_list)
+ legend_hbox = QHBoxLayout()
+ legend_hbox.setContentsMargins(0, 0, 0, 0)
+ legend_hbox.addStretch(2)
+ legend_hbox.addWidget(self.txo_color_parent.legend_label)
+ legend_hbox.addWidget(self.txo_color_uncle.legend_label)
+ vbox.addLayout(legend_hbox)
+ vbox.addLayout(Buttons(CloseButton(self)))
+ self.setLayout(vbox)
+ self.update()
+ self.main_window.labels_changed_signal.connect(self.update)
+
+ def update(self):
+
+ txid = self.utxo.prevout.txid.hex()
+ parents = self.wallet.get_tx_parents(txid)
+ num_parents = len(parents)
+ parents_copy = copy.deepcopy(parents)
+ cursor = self.parents_list.textCursor()
+ ext = QTextCharFormat()
+
+ if num_parents < 200:
+ ASCII_EDGE = '└─'
+ ASCII_BRANCH = '├─'
+ ASCII_PIPE = '│ '
+ ASCII_SPACE = ' '
+ else:
+ ASCII_EDGE = '└'
+ ASCII_BRANCH = '├'
+ ASCII_PIPE = '│'
+ ASCII_SPACE = ' '
+
+ self.parents_list.clear()
+ self.num_reuse = 0
+
+ def print_ascii_tree(_txid, prefix, is_last, is_uncle):
+ if _txid not in parents:
+ return
+ tx_mined_info = self.wallet.adb.get_tx_height(_txid)
+ tx_height = tx_mined_info.height()
+ tx_pos = tx_mined_info.txpos
+ key = "%dx%d"%(tx_height, tx_pos) if tx_pos is not None else _txid[0:8]
+ label = self.wallet.get_label_for_txid(_txid) or ""
+ if _txid not in parents_copy:
+ label = '[duplicate]'
+ c = '' if _txid == txid else (ASCII_EDGE if is_last else ASCII_BRANCH)
+ cursor.insertText(prefix + c, ext)
+ if is_uncle:
+ self.num_reuse += 1
+ lnk = QTextCharFormat(self.txo_color_uncle.text_char_format)
+ else:
+ lnk = QTextCharFormat(self.txo_color_parent.text_char_format)
+ lnk.setToolTip(_('Click to open, right-click for menu'))
+ lnk.setAnchorHref(_txid)
+ #lnk.setAnchorNames([a_name])
+ lnk.setAnchor(True)
+ lnk.setUnderlineStyle(QTextCharFormat.UnderlineStyle.SingleUnderline)
+ cursor.insertText(key, lnk)
+ cursor.insertText(" ", ext)
+ cursor.insertText(label, ext)
+ cursor.insertBlock()
+ next_prefix = '' if txid == _txid else prefix + (ASCII_SPACE if is_last else ASCII_PIPE)
+ parents_list, uncle_list = parents_copy.pop(_txid, ([],[]))
+ for i, p in enumerate(parents_list + uncle_list):
+ is_last = (i == len(parents_list) + len(uncle_list)- 1)
+ is_uncle = (i > len(parents_list) - 1)
+ print_ascii_tree(p, next_prefix, is_last, is_uncle)
+
+ # recursively build the tree
+ print_ascii_tree(txid, '', False, False)
+ msg = _("This UTXO has {} parent transactions in your wallet.").format(num_parents)
+ if self.num_reuse:
+ msg += '\n' + _('This does not include transactions that are downstream of address reuse.')
+ self.stats_label.setText(msg)
+ self.txo_color_parent.legend_label.setVisible(True)
+ self.txo_color_uncle.legend_label.setVisible(bool(self.num_reuse))
+ # set cursor to top
+ cursor.setPosition(0)
+ self.parents_list.setTextCursor(cursor)
+
+ def open_tx(self, txid):
+ if isinstance(txid, QUrl):
+ txid = txid.toString(QUrl.UrlFormattingOption.None_)
+ tx = self.wallet.adb.get_transaction(txid)
+ if not tx:
+ return
+ self.main_window.show_transaction(tx)
diff --git a/electrum/gui/qt/utxo_list.py b/electrum/gui/qt/utxo_list.py
new file mode 100644
index 000000000000..94c94e0ef24b
--- /dev/null
+++ b/electrum/gui/qt/utxo_list.py
@@ -0,0 +1,383 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2015 Thomas Voegtlin
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from typing import Optional, List, Dict, Sequence, Set, TYPE_CHECKING
+import enum
+import copy
+
+from PyQt6.QtCore import Qt
+from PyQt6.QtGui import QStandardItemModel, QStandardItem, QFont
+from PyQt6.QtWidgets import QAbstractItemView, QMenu
+
+from electrum.i18n import _
+from electrum.bitcoin import is_address
+from electrum.transaction import PartialTxInput, PartialTxOutput
+from electrum.lnutil import MIN_FUNDING_SAT
+from electrum.util import profiler
+from electrum.plugin import run_hook
+
+from .util import ColorScheme, MONOSPACE_FONT
+from .my_treeview import MyTreeView, MySortModel
+from .new_channel_dialog import NewChannelDialog
+from ..messages import MSG_FREEZE_ADDRESS, MSG_FREEZE_COIN
+
+if TYPE_CHECKING:
+ from .main_window import ElectrumWindow
+
+
+class UTXOList(MyTreeView):
+ _spend_set: Set[str] # coins selected by the user to spend from
+ _utxo_dict: Dict[str, PartialTxInput] # coin name -> coin
+
+ class Columns(MyTreeView.BaseColumnsEnum):
+ OUTPOINT = enum.auto()
+ ADDRESS = enum.auto()
+ LABEL = enum.auto()
+ AMOUNT = enum.auto()
+ PARENTS = enum.auto()
+
+ headers = {
+ Columns.OUTPOINT: _('Output point'),
+ Columns.ADDRESS: _('Address'),
+ Columns.PARENTS: _('Parents'),
+ Columns.LABEL: _('Label'),
+ Columns.AMOUNT: _('Amount'),
+ }
+ filter_columns = [Columns.ADDRESS, Columns.LABEL, Columns.OUTPOINT]
+ stretch_column = Columns.LABEL
+
+ ROLE_PREVOUT_STR = Qt.ItemDataRole.UserRole + 1000
+ ROLE_SORT_ORDER = Qt.ItemDataRole.UserRole + 1001
+ key_role = ROLE_PREVOUT_STR
+
+ def __init__(self, main_window: 'ElectrumWindow'):
+ super().__init__(
+ main_window=main_window,
+ stretch_column=self.stretch_column,
+ )
+ self._spend_set = set()
+ self._utxo_dict = {}
+ self.wallet = self.main_window.wallet
+ 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.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
+ self.setSortingEnabled(True)
+
+ def create_toolbar(self, config):
+ toolbar, menu = self.create_toolbar_with_menu('')
+ self.num_coins_label = toolbar.itemAt(0).widget()
+ menu.addAction(_('Coin control'), lambda: self.add_selection_to_coincontrol())
+
+ def cb():
+ self.main_window.utxo_list.refresh_all() # for coin frozen status
+ self.main_window.update_status() # frozen balance
+ menu.addConfig(config.cv.WALLET_FREEZE_REUSED_ADDRESS_UTXOS, callback=cb)
+ return toolbar
+
+ @profiler(min_threshold=0.05)
+ def update(self):
+ # not calling maybe_defer_update() as it interferes with coincontrol status bar
+ self.proxy.setDynamicSortFilter(False) # temp. disable re-sorting after every change
+ utxos = self.wallet.get_utxos()
+ self._maybe_reset_coincontrol(utxos)
+ self._utxo_dict = dict([(utxo.prevout.to_str(), utxo) for utxo in utxos])
+ self.std_model.clear()
+ self.update_headers(self.__class__.headers)
+ for idx, utxo in enumerate(utxos):
+ name = utxo.prevout.to_str()
+ labels = [""] * len(self.Columns)
+ amount_str = self.main_window.format_amount(
+ utxo.value_sats(), whitespaces=True)
+ amount_str_nots = self.main_window.format_amount(
+ utxo.value_sats(), whitespaces=False, add_thousands_sep=False)
+ labels[self.Columns.OUTPOINT] = str(utxo.short_id)
+ labels[self.Columns.ADDRESS] = utxo.address
+ labels[self.Columns.AMOUNT] = amount_str
+ utxo_item = [QStandardItem(x) for x in labels]
+ self.set_editability(utxo_item)
+ utxo_item[self.Columns.OUTPOINT].setData(name, self.ROLE_PREVOUT_STR)
+ utxo_item[self.Columns.AMOUNT].setData(amount_str_nots, self.ROLE_CLIPBOARD_DATA)
+ utxo_item[self.Columns.ADDRESS].setFont(QFont(MONOSPACE_FONT))
+ utxo_item[self.Columns.AMOUNT].setFont(QFont(MONOSPACE_FONT))
+ utxo_item[self.Columns.PARENTS].setFont(QFont(MONOSPACE_FONT))
+ utxo_item[self.Columns.OUTPOINT].setFont(QFont(MONOSPACE_FONT))
+ self.std_model.insertRow(idx, utxo_item)
+ self.refresh_row(name, idx)
+ self.filter()
+ self.proxy.setDynamicSortFilter(True)
+ self.sortByColumn(self.Columns.OUTPOINT, Qt.SortOrder.DescendingOrder)
+ self.update_coincontrol_bar()
+ self.num_coins_label.setText(_('{} unspent transaction outputs').format(len(utxos)))
+
+ def update_coincontrol_bar(self):
+ # update coincontrol status bar
+ if bool(self._spend_set):
+ coins = [self._utxo_dict[x] for x in self._spend_set]
+ coins = self._filter_frozen_coins(coins)
+ amount = sum(x.value_sats() for x in coins)
+ amount_str = self.main_window.format_amount_and_units(amount)
+ num_outputs_str = _("{} outputs available ({} total)").format(len(coins), len(self._utxo_dict))
+ self.main_window.set_coincontrol_msg(_("Coin control active") + f': {num_outputs_str}, {amount_str}')
+ else:
+ self.main_window.set_coincontrol_msg(None)
+
+ def refresh_row(self, key, row):
+ assert row is not None
+ utxo = self._utxo_dict[key]
+ utxo_item = [self.std_model.item(row, col) for col in self.Columns]
+ txid = utxo.prevout.txid.hex()
+ num_parents = self.wallet.get_num_parents(txid)
+ utxo_item[self.Columns.PARENTS].setText('%6s'%num_parents if num_parents else '-')
+ label = self.wallet.get_label_for_txid(txid) or ''
+ utxo_item[self.Columns.LABEL].setText(label)
+ sort_key = (
+ self.wallet.adb.tx_height_to_sort_height(utxo.block_height), # sort by block height
+ str(utxo.short_id), # order inside block (if mined), or just txid
+ )
+ utxo_item[self.Columns.OUTPOINT].setData(sort_key, self.ROLE_SORT_ORDER)
+ SELECTED_TO_SPEND_TOOLTIP = _('Coin selected to be spent')
+ if key in self._spend_set:
+ tooltip = key + "\n" + SELECTED_TO_SPEND_TOOLTIP
+ color = ColorScheme.GREEN.as_color(True)
+ else:
+ tooltip = key
+ color = self._default_bg_brush
+ for col in utxo_item:
+ col.setBackground(color)
+ col.setToolTip(tooltip)
+ if self.wallet.is_frozen_address(utxo.address):
+ utxo_item[self.Columns.ADDRESS].setBackground(ColorScheme.BLUE.as_color(True))
+ utxo_item[self.Columns.ADDRESS].setToolTip(_('Address is frozen'))
+ if self.wallet.is_frozen_coin(utxo):
+ utxo_item[self.Columns.OUTPOINT].setBackground(ColorScheme.BLUE.as_color(True))
+ utxo_item[self.Columns.OUTPOINT].setToolTip(f"{key}\n{_('Coin is frozen')}")
+
+ def get_selected_outpoints(self) -> List[str]:
+ if not self.model():
+ return []
+ items = self.selected_in_column(self.Columns.OUTPOINT)
+ return [x.data(self.ROLE_PREVOUT_STR) for x in items]
+
+ def _filter_frozen_coins(self, coins: List[PartialTxInput]) -> List[PartialTxInput]:
+ coins = [utxo for utxo in coins
+ if (not self.wallet.is_frozen_address(utxo.address) and
+ not self.wallet.is_frozen_coin(utxo))]
+ return coins
+
+ def are_in_coincontrol(self, coins: List[PartialTxInput]) -> bool:
+ return all([utxo.prevout.to_str() in self._spend_set for utxo in coins])
+
+ def add_to_coincontrol(self, coins: List[PartialTxInput]):
+ assert all(utxo.prevout.to_str() in self._utxo_dict for utxo in coins) # see issue 10206
+ coins = self._filter_frozen_coins(coins)
+ for utxo in coins:
+ self._spend_set.add(utxo.prevout.to_str())
+ self._refresh_coincontrol()
+
+ def remove_from_coincontrol(self, coins: List[PartialTxInput]):
+ for utxo in coins:
+ self._spend_set.remove(utxo.prevout.to_str())
+ self._refresh_coincontrol()
+
+ def clear_coincontrol(self):
+ self._spend_set.clear()
+ self._refresh_coincontrol()
+
+ def add_selection_to_coincontrol(self):
+ if bool(self._spend_set):
+ self.clear_coincontrol()
+ return
+ selected = self.get_selected_outpoints()
+ coins = [self._utxo_dict[name] for name in selected]
+ if not coins:
+ self.main_window.show_error(_('You need to select coins from the list first.\nUse ctrl+left mouse button to select multiple items'))
+ return
+ self.add_to_coincontrol(coins)
+
+ def _refresh_coincontrol(self):
+ self.refresh_all()
+ self.update_coincontrol_bar()
+ self.selectionModel().clearSelection()
+
+ def get_spend_list(self) -> Optional[Sequence[PartialTxInput]]:
+ if not bool(self._spend_set):
+ return None
+ utxos = [self._utxo_dict[x] for x in self._spend_set]
+ return copy.deepcopy(utxos) # copy so that side-effects don't affect utxo_dict
+
+ def _maybe_reset_coincontrol(self, current_wallet_utxos: Sequence[PartialTxInput]) -> None:
+ if not self._spend_set and not self._currently_open_menu:
+ return
+ utxo_set = {utxo.prevout.to_str() for utxo in current_wallet_utxos}
+ if self._currently_open_menu:
+ # if we spent one of the qt-highlighted UTXOs, close context-menu
+ if not all(prevout_str in utxo_set for prevout_str in self.get_selected_outpoints()):
+ self.close_menu()
+ if self._spend_set:
+ # if we spent one of the green-marked UTXOs, just reset selection
+ if not all([prevout_str in utxo_set for prevout_str in self._spend_set]):
+ self._spend_set.clear()
+
+ def can_swap_coins(self, coins):
+ # fixme: min and max_amounts are known only after first request
+ if self.wallet.lnworker is None:
+ return False
+ value = sum(x.value_sats() for x in coins)
+ min_amount = self.wallet.lnworker.swap_manager.get_min_amount()
+ max_amount = self.wallet.lnworker.swap_manager.client_max_amount_forward_swap()
+ if min_amount is None or max_amount is None:
+ # we need to fetch data from swap server
+ return True
+ if value < min_amount:
+ return False
+ if max_amount is None or value > max_amount:
+ return False
+ return True
+
+ def swap_coins(self, coins: list[PartialTxInput]) -> None:
+ assert coins, "no coins selected?"
+ self.main_window.run_swap_dialog(is_reverse=False, recv_amount_sat_or_max='!', get_coins=lambda *args, **kwargs: coins)
+
+ def can_open_channel(self, coins):
+ if self.wallet.lnworker is None:
+ return False
+ value = sum(x.value_sats() for x in coins)
+ return value >= MIN_FUNDING_SAT and value <= self.config.LIGHTNING_MAX_FUNDING_SAT
+
+ def open_channel_with_coins(self, coins: list[PartialTxInput]) -> None:
+ assert coins, "no coins selected?"
+ # todo : use a single dialog in new flow
+ d = NewChannelDialog(self.main_window, get_coins=lambda *args, **kwargs: coins)
+ d.max_button.setChecked(True)
+ d.max_button.setEnabled(False)
+ d.min_button.setEnabled(False)
+ d.clear_button.setEnabled(False)
+ d.amount_e.setFrozen(True)
+ d.spend_max()
+ d.run()
+
+ def clipboard_contains_address(self) -> bool:
+ text = self.main_window.app.clipboard().text()
+ return is_address(text)
+
+ def pay_to_clipboard_address(self, coins: list[PartialTxInput]) -> None:
+ assert coins, "no coins selected?"
+ if not self.clipboard_contains_address():
+ self.main_window.show_error(_('Clipboard doesn\'t contain a valid address'))
+ return
+ addr = self.main_window.app.clipboard().text()
+ outputs = [PartialTxOutput.from_address_and_value(addr, '!')]
+
+ self.main_window.send_tab.pay_onchain_dialog(outputs, get_coins=lambda *args, **kwargs: coins)
+
+ def on_double_click(self, idx):
+ outpoint = idx.sibling(idx.row(), self.Columns.OUTPOINT).data(self.ROLE_PREVOUT_STR)
+ utxo = self._utxo_dict[outpoint]
+ self.main_window.show_utxo(utxo)
+
+ def create_menu(self, position):
+ selected = self.get_selected_outpoints()
+ coins = [self._utxo_dict[name] for name in selected]
+
+ if not coins:
+ return
+
+ unfrozen_coins = self._filter_frozen_coins(coins)
+ menu = QMenu()
+ menu.setSeparatorsCollapsible(True) # consecutive separators are merged together
+
+ if len(coins) == 1:
+ idx = self.indexAt(position)
+ if not idx.isValid():
+ return
+ utxo = coins[0]
+ txid = utxo.prevout.txid.hex()
+ # "Details"
+ tx = self.wallet.adb.get_transaction(txid)
+ if tx:
+ label = self.wallet.get_label_for_txid(txid)
+ menu.addAction(_("Privacy analysis"), lambda: self.main_window.show_utxo(utxo))
+ cc = self.add_copy_menu(menu, idx)
+ cc.addAction(_("Long Output point"), lambda: self.place_text_on_clipboard(utxo.prevout.to_str(), title="Long Output point"))
+ # fully spend
+ m = menu_spend = menu.addMenu(_("Fully spend") + '…')
+ m.setEnabled(bool(unfrozen_coins))
+ m = menu_spend.addAction(_("send to address in clipboard"), lambda: self.pay_to_clipboard_address(unfrozen_coins))
+ m.setEnabled(self.clipboard_contains_address())
+ m = menu_spend.addAction(_("in new channel"), lambda: self.open_channel_with_coins(unfrozen_coins))
+ m.setEnabled(self.can_open_channel(unfrozen_coins))
+ m = menu_spend.addAction(_("in submarine swap"), lambda: self.swap_coins(unfrozen_coins))
+ m.setEnabled(self.can_swap_coins(unfrozen_coins))
+ # coin control
+ if self.are_in_coincontrol(coins):
+ menu.addAction(_("Remove from coin control"), lambda: self.remove_from_coincontrol(coins))
+ else:
+ m = menu.addAction(_("Add to coin control"), lambda: self.add_to_coincontrol(coins))
+ m.setEnabled(bool(unfrozen_coins))
+ # Freeze menu
+ if len(coins) == 1:
+ utxo = coins[0]
+ addr = utxo.address
+ menu_freeze = menu.addMenu(_("Freeze"))
+ menu_freeze.setToolTipsVisible(True)
+ if not self.wallet.is_frozen_coin(utxo):
+ act = menu_freeze.addAction(_("Freeze Coin"), lambda: self.main_window.set_frozen_state_of_coins([utxo], True))
+ else:
+ act = menu_freeze.addAction(_("Unfreeze Coin"), lambda: self.main_window.set_frozen_state_of_coins([utxo], False))
+ act.setToolTip(MSG_FREEZE_COIN)
+ if not self.wallet.is_frozen_address(addr):
+ act = menu_freeze.addAction(_("Freeze Address"), lambda: self.main_window.set_frozen_state_of_addresses([addr], True))
+ else:
+ act = menu_freeze.addAction(_("Unfreeze Address"), lambda: self.main_window.set_frozen_state_of_addresses([addr], False))
+ act.setToolTip(MSG_FREEZE_ADDRESS)
+ elif len(coins) > 1: # multiple items selected
+ menu.addSeparator()
+ addrs = [utxo.address for utxo in coins]
+ is_coin_frozen = [self.wallet.is_frozen_coin(utxo) for utxo in coins]
+ is_addr_frozen = [self.wallet.is_frozen_address(utxo.address) for utxo in coins]
+ menu_freeze = menu.addMenu(_("Freeze"))
+ menu_freeze.setToolTipsVisible(True)
+ if not all(is_coin_frozen):
+ act = menu_freeze.addAction(_("Freeze Coins"), lambda: self.main_window.set_frozen_state_of_coins(coins, True))
+ act.setToolTip(MSG_FREEZE_COIN)
+ if any(is_coin_frozen):
+ act = menu_freeze.addAction(_("Unfreeze Coins"), lambda: self.main_window.set_frozen_state_of_coins(coins, False))
+ act.setToolTip(MSG_FREEZE_COIN)
+ if not all(is_addr_frozen):
+ act = menu_freeze.addAction(_("Freeze Addresses"), lambda: self.main_window.set_frozen_state_of_addresses(addrs, True))
+ act.setToolTip(MSG_FREEZE_ADDRESS)
+ if any(is_addr_frozen):
+ act = menu_freeze.addAction(_("Unfreeze Addresses"), lambda: self.main_window.set_frozen_state_of_addresses(addrs, False))
+ act.setToolTip(MSG_FREEZE_ADDRESS)
+
+ run_hook('qt_utxo_menu', menu, coins, self.wallet)
+ self.open_menu(menu, position)
+
+ def get_filter_data_from_coordinate(self, row, col):
+ if col == self.Columns.OUTPOINT:
+ return self.get_role_data_from_coordinate(row, col, role=self.ROLE_PREVOUT_STR)
+ return super().get_filter_data_from_coordinate(row, col)
diff --git a/electrum/gui/qt/wallet_info_dialog.py b/electrum/gui/qt/wallet_info_dialog.py
new file mode 100644
index 000000000000..1f826975dbdf
--- /dev/null
+++ b/electrum/gui/qt/wallet_info_dialog.py
@@ -0,0 +1,216 @@
+# Copyright (C) 2023 The Electrum developers
+# Distributed under the MIT software license, see the accompanying
+# file LICENCE or http://www.opensource.org/licenses/mit-license.php
+
+import os
+from typing import TYPE_CHECKING
+from functools import partial
+
+from PyQt6.QtCore import Qt
+from PyQt6.QtWidgets import (
+ QLabel, QVBoxLayout, QGridLayout,
+ QHBoxLayout, QPushButton, QWidget, QTabWidget)
+
+from electrum.plugin import run_hook
+from electrum.i18n import _
+from electrum.wallet import Multisig_Wallet
+from electrum.wizard import WizardViewState
+
+from .main_window import protected
+from electrum.gui.qt.wizard.wallet import QEKeystoreWizard
+from .qrtextedit import ShowQRTextEdit
+from .util import (
+ read_QIcon, WindowModalDialog, Buttons,
+ WWLabel, CloseButton, HelpButton, font_height, ShowQRLineEdit
+)
+
+if TYPE_CHECKING:
+ from .main_window import ElectrumWindow
+
+
+class WalletInfoDialog(WindowModalDialog):
+
+ def __init__(self, parent: QWidget, *, window: 'ElectrumWindow'):
+ WindowModalDialog.__init__(self, parent, _("Wallet Information"))
+ self.setMinimumSize(800, 100)
+ self.window = window
+ self.wallet = wallet = window.wallet
+ # required for @protected decorator
+ self._protected_requires_password = lambda: self.wallet.has_keystore_encryption() or self.wallet.storage.is_encrypted_with_user_pw()
+ config = window.config
+ vbox = QVBoxLayout()
+ wallet_type = wallet.db.get('wallet_type', '')
+ if wallet.is_watching_only():
+ wallet_type += ' [{}]'.format(_('watching-only'))
+ seed_available = _('False')
+ if wallet.has_seed():
+ seed_available = _('True')
+ seed_available += f" ({wallet.get_seed_type()})"
+ keystore_types = [k.get_type_text() for k in wallet.get_keystores()]
+ grid = QGridLayout()
+ basename = os.path.basename(wallet.storage.get_path())
+ cur_row = 0
+ grid.addWidget(WWLabel(_("Wallet name")+ ':'), cur_row, 0)
+ grid.addWidget(WWLabel(basename), cur_row, 1)
+ cur_row += 1
+ if db_metadata := wallet.db.get_db_metadata():
+ grid.addWidget(WWLabel(_("File created") + ':'), cur_row, 0)
+ grid.addWidget(WWLabel(db_metadata.to_str()), cur_row, 1)
+ cur_row += 1
+ grid.addWidget(WWLabel(_("Wallet type")+ ':'), cur_row, 0)
+ grid.addWidget(WWLabel(wallet_type), cur_row, 1)
+ cur_row += 1
+ grid.addWidget(WWLabel(_("Script type")+ ':'), cur_row, 0)
+ grid.addWidget(WWLabel(wallet.txin_type), cur_row, 1)
+ cur_row += 1
+ grid.addWidget(WWLabel(_("Seed available") + ':'), cur_row, 0)
+ grid.addWidget(WWLabel(str(seed_available)), cur_row, 1)
+ cur_row += 1
+ if len(keystore_types) <= 1:
+ grid.addWidget(WWLabel(_("Keystore type") + ':'), cur_row, 0)
+ ks_type = str(keystore_types[0]) if keystore_types else _('No keystore')
+ grid.addWidget(WWLabel(ks_type), cur_row, 1)
+ cur_row += 1
+ # lightning
+ grid.addWidget(WWLabel(_('Lightning') + ':'), cur_row, 0)
+ from .util import IconLabel
+ if wallet.has_lightning():
+ if wallet.lnworker.has_deterministic_node_id():
+ grid.addWidget(WWLabel(_('Enabled')), cur_row, 1)
+ else:
+ label = IconLabel(text='Enabled, non-recoverable channels')
+ label.setIcon(read_QIcon('cloud_no'))
+ grid.addWidget(label, cur_row, 1)
+ if wallet.get_seed_type() == 'segwit':
+ msg = _("Your channels cannot be recovered from seed, because they were created with an old version of Electrum. "
+ "This means that you must save a backup of your wallet every time you create a new channel.\n\n"
+ "If you want this wallet to have recoverable channels, you must close your existing channels and restore this wallet from seed")
+ else:
+ msg = _("Your channels cannot be recovered from seed. "
+ "This means that you must save a backup of your wallet every time you create a new channel.\n\n"
+ "If you want to have recoverable channels, you must create a new wallet with an Electrum seed")
+ grid.addWidget(HelpButton(msg), cur_row, 3)
+ cur_row += 1
+ grid.addWidget(WWLabel(_('Lightning Node ID:')), cur_row, 0)
+ cur_row += 1
+ nodeid_text = wallet.lnworker.node_keypair.pubkey.hex()
+ nodeid_e = ShowQRLineEdit(nodeid_text, config, title=_("Node ID"))
+ grid.addWidget(nodeid_e, cur_row, 0, 1, 4)
+ cur_row += 1
+ else:
+ if wallet.can_have_lightning():
+ grid.addWidget(WWLabel('Not enabled'), cur_row, 1)
+ button = QPushButton(_("Enable"))
+ button.pressed.connect(lambda: window.init_lightning_dialog(self))
+ grid.addWidget(button, cur_row, 3)
+ else:
+ grid.addWidget(WWLabel(_("Not available for this wallet.")), cur_row, 1)
+ grid.addWidget(HelpButton(_("Lightning is currently restricted to HD wallets with p2wpkh addresses.")), cur_row, 2)
+ cur_row += 1
+ vbox.addLayout(grid)
+
+ labels_clayout = None
+
+ if wallet.is_deterministic():
+ keystores = sorted(wallet.get_keystores(), key=lambda _ks: _ks.get_root_fingerprint() or '')
+
+ self.keystore_tabs = QTabWidget()
+
+ for idx, ks in enumerate(keystores):
+ ks_w = QWidget()
+ ks_vbox = QVBoxLayout()
+ ks_w.setLayout(ks_vbox)
+
+ status_label = _('This keystore is watching-only (disabled)') if ks.is_watching_only() else _('This keystore is active (enabled)')
+ ks_vbox.addWidget(QLabel(status_label))
+ label = f'{ks.label}' if hasattr(ks, 'label') and ks.label else ''
+ ks_vbox.addWidget(QLabel(_('Type') + ': ' + f'{ks.get_type_text()}' + ' ' + label))
+
+ mpk_text = ShowQRTextEdit(ks.get_master_public_key(), config=config)
+ mpk_text.setMaximumHeight(max(150, 10 * font_height()))
+ mpk_text.addCopyButton()
+ run_hook('show_xpub_button', mpk_text, ks)
+ ks_vbox.addWidget(WWLabel(_("Master Public Key")))
+ ks_vbox.addWidget(mpk_text)
+
+ der_path_hbox = QHBoxLayout()
+ der_path_hbox.setContentsMargins(0, 0, 0, 0)
+ der_path_hbox.addWidget(WWLabel(_("Derivation path") + ':'))
+ der_path_text = WWLabel(ks.get_derivation_prefix() or _("unknown"))
+ der_path_text.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
+ der_path_hbox.addWidget(der_path_text)
+ der_path_hbox.addStretch()
+ ks_vbox.addLayout(der_path_hbox)
+
+ bip32fp_hbox = QHBoxLayout()
+ bip32fp_hbox.setContentsMargins(0, 0, 0, 0)
+ bip32fp_hbox.addWidget(QLabel("BIP32 root fingerprint:"))
+ bip32fp_text = WWLabel(ks.get_root_fingerprint() or _("unknown"))
+ bip32fp_text.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
+ bip32fp_hbox.addWidget(bip32fp_text)
+ bip32fp_hbox.addStretch()
+ ks_vbox.addLayout(bip32fp_hbox)
+ if wallet.can_enable_disable_keystore(ks):
+ ks_buttons = []
+ if not ks.is_watching_only():
+ rm_keystore_button = QPushButton('Disable keystore')
+ rm_keystore_button.clicked.connect(partial(self.disable_keystore, ks))
+ ks_buttons.insert(0, rm_keystore_button)
+ else:
+ add_keystore_button = QPushButton('Enable Keystore')
+ add_keystore_button.clicked.connect(self.enable_keystore)
+ ks_buttons.insert(0, add_keystore_button)
+ ks_vbox.addLayout(Buttons(*ks_buttons))
+ tab_label = _("Cosigner") + f' {idx+1}' if len(keystores) > 1 else _("Keystore")
+ index = self.keystore_tabs.addTab(ks_w, tab_label)
+ if not ks.is_watching_only():
+ self.keystore_tabs.setTabIcon(index, read_QIcon('confirmed.svg'))
+ vbox.addWidget(self.keystore_tabs)
+
+ vbox.addStretch(1)
+
+ buttons = [CloseButton(self)]
+ btn_export_info = run_hook('wallet_info_buttons', window, self)
+ if btn_export_info is None:
+ btn_export_info = []
+ buttons = btn_export_info + buttons
+
+ btns = Buttons(*buttons)
+ vbox.addLayout(btns)
+ self.setLayout(vbox)
+
+ def disable_keystore(self, keystore):
+ if self.wallet.has_channels():
+ self.window.show_message(_('Cannot disable keystore: You have active lightning channels'))
+ return
+
+ msg = _('Disable keystore? This will make the keystore watching-only.')
+ if self.wallet.storage.is_encrypted_with_hw_device():
+ msg += '\n\n' + _('Note that this will disable wallet file encryption, because it uses your hardware wallet device.')
+ if not self.window.question(msg):
+ return
+ self.accept()
+ self.wallet.disable_keystore(keystore)
+ self.window.gui_object.reload_windows()
+
+ def enable_keystore(self, b: bool):
+ v = WizardViewState('keystore_type', {'wallet_type': self.window.wallet.wallet_type}, {})
+ dialog = QEKeystoreWizard(config=self.window.config, app=self.window.gui_object.app,
+ plugins=self.window.gui_object.plugins, start_viewstate=v)
+ result = dialog.run()
+ if not result:
+ return
+ keystore, is_hardware = result
+ for k in self.wallet.get_keystores():
+ if k.get_master_public_key() == keystore.get_master_public_key():
+ break
+ else:
+ self.window.show_error(_('Keystore not found in this wallet'))
+ return
+ self._enable_keystore(keystore, is_hardware)
+
+ @protected
+ def _enable_keystore(self, keystore, is_hardware, password):
+ self.accept()
+ self.wallet.enable_keystore(keystore, is_hardware, password)
+ self.window.gui_object.reload_windows()
diff --git a/electrum/gui/qt/wizard/__init__.py b/electrum/gui/qt/wizard/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/electrum/gui/qt/wizard/server_connect.py b/electrum/gui/qt/wizard/server_connect.py
new file mode 100644
index 000000000000..19b403d1309a
--- /dev/null
+++ b/electrum/gui/qt/wizard/server_connect.py
@@ -0,0 +1,102 @@
+from typing import TYPE_CHECKING
+
+from PyQt6.QtCore import Qt
+from PyQt6.QtGui import QPixmap
+from PyQt6.QtWidgets import QCheckBox, QLabel, QHBoxLayout, QVBoxLayout, QWidget
+
+from electrum.i18n import _
+from electrum.wizard import ServerConnectWizard
+from electrum.gui.qt.network_dialog import ProxyWidget, ServerWidget
+from electrum.gui.qt.util import icon_path
+from .wizard import QEAbstractWizard, WizardComponent
+
+if TYPE_CHECKING:
+ from electrum.simple_config import SimpleConfig
+ from electrum.plugin import Plugins
+ from electrum.daemon import Daemon
+ from electrum.gui.qt import QElectrumApplication
+
+
+class QEServerConnectWizard(ServerConnectWizard, QEAbstractWizard):
+
+ def __init__(self, config: 'SimpleConfig', app: 'QElectrumApplication', plugins: 'Plugins', daemon: 'Daemon', parent=None):
+ ServerConnectWizard.__init__(self, daemon)
+ QEAbstractWizard.__init__(self, config, app)
+ self.window_title = _('Network and server configuration')
+ self.finish_label = _('Next')
+
+ # attach gui classes
+ self.navmap_merge({
+ 'welcome': {'gui': WCWelcome},
+ 'proxy_config': {'gui': WCProxyConfig},
+ 'server_config': {'gui': WCServerConfig},
+ })
+
+
+class WCWelcome(WizardComponent):
+ def __init__(self, parent, wizard):
+ WizardComponent.__init__(self, parent, wizard, title='Network Configuration')
+ self.wizard_title = _('Electrum Bitcoin Wallet')
+
+ self.first_help_label = QLabel()
+ self.first_help_label.setText(_("Optional settings to customize your network connection") + ":")
+ self.first_help_label.setWordWrap(True)
+
+ self.config_proxy_w = QCheckBox(_('Use Proxy'))
+ self.config_proxy_w.setChecked(False)
+ self.config_proxy_w.stateChanged.connect(self.on_updated)
+ self.config_server_w = QCheckBox(_('Select Electrum Server'))
+ self.config_server_w.setChecked(False)
+ self.config_server_w.stateChanged.connect(self.on_updated)
+ options_w = QWidget()
+ vbox = QVBoxLayout()
+ vbox.addWidget(self.config_proxy_w)
+ vbox.addWidget(self.config_server_w)
+ options_w.setLayout(vbox)
+
+ self.second_help_label = QLabel()
+ self.second_help_label.setText(
+ _("If you are unsure what these options are, leave them unchecked.")
+ )
+ self.second_help_label.setWordWrap(True)
+
+ self.layout().addWidget(self.first_help_label)
+ self.layout().addWidget(options_w)
+ self.layout().addWidget(self.second_help_label)
+ self.layout().addStretch(1)
+ self._valid = True
+
+ def apply(self):
+ self.wizard_data['use_defaults'] = not (self.config_server_w.isChecked() or self.config_proxy_w.isChecked())
+ self.wizard_data['want_proxy'] = self.config_proxy_w.isChecked()
+ self.wizard_data['autoconnect'] = not self.config_server_w.isChecked()
+
+
+class WCProxyConfig(WizardComponent):
+ def __init__(self, parent, wizard):
+ WizardComponent.__init__(self, parent, wizard, title=_('Proxy'))
+ self.pw = ProxyWidget(wizard._daemon.network, self)
+ self.pw.proxy_cb.setChecked(True)
+ self.pw.proxy_host.setText('localhost')
+ self.pw.proxy_port.setText('9050')
+ self.layout().addWidget(self.pw)
+ self._valid = True
+
+ def apply(self):
+ self.wizard_data['proxy'] = self.pw.get_proxy_settings().to_dict()
+
+
+class WCServerConfig(WizardComponent):
+ def __init__(self, parent, wizard):
+ WizardComponent.__init__(self, parent, wizard, title=_('Server'))
+ self.sw = ServerWidget(wizard._daemon.network, self)
+ self.layout().addWidget(self.sw)
+ self.sw.server_e_valid.connect(self.on_server_e_valid)
+
+ def on_server_e_valid(self, valid):
+ self.valid = valid
+
+ def apply(self):
+ self.wizard_data['autoconnect'] = self.sw.server_e.text().strip() == ''
+ self.wizard_data['server'] = self.sw.server_e.text()
+ self.wizard_data['one_server'] = self.wizard.config.NETWORK_ONESERVER
diff --git a/electrum/gui/qt/wizard/terms_of_use.py b/electrum/gui/qt/wizard/terms_of_use.py
new file mode 100644
index 000000000000..324b67662f99
--- /dev/null
+++ b/electrum/gui/qt/wizard/terms_of_use.py
@@ -0,0 +1,58 @@
+from typing import TYPE_CHECKING
+
+from PyQt6.QtCore import QTimer, QEvent
+from PyQt6.QtGui import QPixmap
+from PyQt6.QtWidgets import QLabel, QHBoxLayout, QScrollArea
+
+from electrum.i18n import _
+from electrum.wizard import TermsOfUseWizard
+from electrum.gui.qt.util import icon_path, WWLabel
+from electrum.gui import messages
+from .wizard import QEAbstractWizard, WizardComponent
+
+if TYPE_CHECKING:
+ from electrum.simple_config import SimpleConfig
+ from electrum.gui.qt import QElectrumApplication
+
+
+class QETermsOfUseWizard(TermsOfUseWizard, QEAbstractWizard):
+ def __init__(self, config: 'SimpleConfig', app: 'QElectrumApplication'):
+ TermsOfUseWizard.__init__(self, config)
+ QEAbstractWizard.__init__(self, config, app)
+ self.window_title = _('Terms of Use')
+ self.finish_label = _('I Accept')
+ self.title.setVisible(False)
+ # self.window().setMinimumHeight(565) # Enough to show the whole text without scrolling
+ self.next_button.setToolTip("You accept the Terms of Use by clicking this button.")
+
+ # attach gui classes
+ self.navmap_merge({
+ 'terms_of_use': {'gui': WCTermsOfUseScreen, 'params': {'icon': ''}},
+ })
+
+class WCTermsOfUseScreen(WizardComponent):
+ def __init__(self, parent, wizard):
+ WizardComponent.__init__(self, parent, wizard, title='')
+ self.wizard_title = _('Electrum Terms of Use')
+ self.img_label = QLabel()
+ pixmap = QPixmap(icon_path('electrum_darkblue_1.png'))
+ self.img_label.setPixmap(pixmap)
+ self.img_label2 = QLabel()
+ pixmap = QPixmap(icon_path('electrum_text.png'))
+ self.img_label2.setPixmap(pixmap)
+ hbox_img = QHBoxLayout()
+ hbox_img.addStretch(1)
+ hbox_img.addWidget(self.img_label)
+ hbox_img.addWidget(self.img_label2)
+ hbox_img.addStretch(1)
+
+ self.layout().addLayout(hbox_img)
+ self.layout().addSpacing(15)
+
+ self.tos_label = WWLabel()
+ self.tos_label.setText(messages.MSG_TERMS_OF_USE)
+ self.layout().addWidget(self.tos_label)
+ self._valid = True
+
+ def apply(self):
+ pass
diff --git a/electrum/gui/qt/wizard/wallet.py b/electrum/gui/qt/wizard/wallet.py
new file mode 100644
index 000000000000..8397b7ba31d4
--- /dev/null
+++ b/electrum/gui/qt/wizard/wallet.py
@@ -0,0 +1,1507 @@
+from abc import ABC
+import os
+import sys
+import threading
+
+from typing import TYPE_CHECKING, Optional, List, Tuple
+
+from PyQt6.QtCore import Qt, QTimer, QRect, pyqtSignal
+from PyQt6.QtGui import QPen, QPainter, QPalette, QPixmap
+from PyQt6.QtWidgets import (QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QWidget,
+ QFileDialog, QSlider, QGridLayout, QDialog, QApplication)
+
+from electrum.bip32 import is_bip32_derivation, BIP32Node, normalize_bip32_derivation, xpub_type
+from electrum.daemon import Daemon
+from electrum.i18n import _
+from electrum.keystore import bip44_derivation, bip39_to_seed, purpose48_derivation, ScriptTypeNotSupported
+from electrum.plugin import run_hook, HardwarePluginLibraryUnavailable
+from electrum.storage import StorageReadWriteError
+from electrum.util import WalletFileException, get_new_wallet_name, UserFacingException, InvalidPassword
+from electrum.util import is_subpath, ChoiceItem, multisig_type, UserCancelled, standardize_path
+from electrum.wallet import wallet_types
+from .wizard import QEAbstractWizard, WizardComponent
+from electrum.logging import get_logger, Logger
+from electrum import WalletStorage, mnemonic, keystore
+from electrum.wallet_db import WalletDB
+from electrum.wizard import NewWalletWizard, KeystoreWizard, WizardViewState
+
+from electrum.gui.qt.bip39_recovery_dialog import Bip39RecoveryDialog
+from electrum.gui.qt.password_dialog import PasswordLayout, PW_NEW, MSG_ENTER_PASSWORD, PasswordLayoutForHW
+from electrum.gui.qt.seed_dialog import SeedWidget, MSG_PASSPHRASE_WARN_ISSUE4566, KeysWidget
+from electrum.gui.qt.util import (PasswordLineEdit, char_width_in_lineedit, WWLabel, InfoButton, font_height,
+ ChoiceWidget, MessageBoxMixin, icon_path, IconLabel, read_QIcon)
+from electrum.gui.qt.plugins_dialog import PluginsDialog
+
+if TYPE_CHECKING:
+ from electrum.simple_config import SimpleConfig
+ from electrum.plugin import Plugins, DeviceInfo
+ from electrum.gui.qt import QElectrumApplication
+
+WIF_HELP_TEXT = (_('WIF keys are typed in Electrum, based on script type.') + '\n\n' +
+ _('A few examples') + ':\n' +
+ 'p2pkh:KxZcY47uGp9a... \t-> 1DckmggQM...\n' +
+ 'p2wpkh-p2sh:KxZcY47uGp9a... \t-> 3NhNeZQXF...\n' +
+ 'p2wpkh:KxZcY47uGp9a... \t-> bc1q3fjfk...')
+
+MSG_HW_STORAGE_ENCRYPTION = _("Set wallet file encryption.") + '\n'\
+ + _("Your wallet file does not contain secrets, mostly just metadata. ") \
+ + _("It also contains your master public key that allows watching your addresses.")
+
+
+class QEKeystoreWizard(KeystoreWizard, QEAbstractWizard, MessageBoxMixin):
+ _logger = get_logger(__name__)
+
+ def __init__(
+ self,
+ *,
+ config: 'SimpleConfig',
+ app: 'QElectrumApplication',
+ plugins: 'Plugins',
+ start_viewstate: WizardViewState = None,
+ ):
+ assert 'wallet_type' in start_viewstate.wizard_data, 'wallet_type required'
+
+ QEAbstractWizard.__init__(self, config, app, start_viewstate=start_viewstate)
+ KeystoreWizard.__init__(self, plugins)
+ self.window_title = _('Extend wallet keystore')
+ # attach gui classes to views
+ self.navmap_merge({
+ 'keystore_type': {'gui': WCExtendKeystore},
+ 'enter_seed': {'gui': WCHaveSeed},
+ 'enter_ext': {'gui': WCEnterExt},
+ 'choose_hardware_device': {'gui': WCChooseHWDevice},
+ 'script_and_derivation': {'gui': WCScriptAndDerivation},
+ 'wallet_password': {'gui': WCWalletPassword},
+ 'wallet_password_hardware': {'gui': WCWalletPasswordHardware},
+ })
+
+ def is_single_password(self):
+ return True
+
+ def run(self):
+ if self.exec() == QDialog.DialogCode.Rejected:
+ return
+ return self._result
+
+
+class QENewWalletWizard(NewWalletWizard, QEAbstractWizard, MessageBoxMixin):
+ _logger = get_logger(__name__)
+
+ def __init__(self, config: 'SimpleConfig', app: 'QElectrumApplication', plugins: 'Plugins', daemon: Daemon, path, *, start_viewstate=None):
+ NewWalletWizard.__init__(self, daemon, plugins)
+ QEAbstractWizard.__init__(self, config, app, start_viewstate=start_viewstate)
+ self.window_title = _('Create/Restore wallet')
+
+ self._path = standardize_path(path)
+ self._password = None
+
+ # attach gui classes to views
+ self.navmap_merge({
+ 'wallet_name': {'gui': WCWalletName},
+ 'hw_unlock': {'gui': WCChooseHWDevice},
+ 'wallet_type': {'gui': WCWalletType},
+ 'keystore_type': {'gui': WCKeystoreType},
+ 'create_seed': {'gui': WCCreateSeed},
+ 'create_ext': {'gui': WCEnterExt},
+ 'confirm_seed': {'gui': WCConfirmSeed},
+ 'confirm_ext': {'gui': WCConfirmExt},
+ 'have_seed': {'gui': WCHaveSeed},
+ 'have_ext': {'gui': WCEnterExt},
+ 'choose_hardware_device': {'gui': WCChooseHWDevice},
+ 'script_and_derivation': {'gui': WCScriptAndDerivation},
+ 'have_master_key': {'gui': WCHaveMasterKey},
+ 'multisig': {'gui': WCMultisig},
+ 'multisig_cosigner_keystore': {'gui': WCCosignerKeystore},
+ 'multisig_cosigner_key': {'gui': WCHaveMasterKey},
+ 'multisig_cosigner_seed': {'gui': WCHaveSeed},
+ 'multisig_cosigner_have_ext': {'gui': WCEnterExt},
+ 'multisig_cosigner_hardware': {'gui': WCChooseHWDevice},
+ 'multisig_cosigner_script_and_derivation': {'gui': WCScriptAndDerivation},
+ 'imported': {'gui': WCImport},
+ 'wallet_password': {'gui': WCWalletPassword},
+ 'wallet_password_hardware': {'gui': WCWalletPasswordHardware}
+ })
+
+ # add open existing wallet from wizard
+ self.navmap_merge({
+ 'wallet_name': {
+ 'next': lambda d: 'hw_unlock' if d['wallet_needs_hw_unlock'] else 'wallet_type',
+ 'last': lambda d: d['wallet_exists'] and not d['wallet_needs_hw_unlock']
+ },
+ })
+
+ run_hook('init_wallet_wizard', self)
+
+ @property
+ def path(self):
+ return self._path
+
+ @path.setter
+ def path(self, path):
+ self._path = path
+
+ def is_single_password(self):
+ # not supported on desktop
+ return False
+
+ def create_storage(self, single_password: str = None):
+ self._logger.info('Creating wallet from wizard data')
+ data = self.get_wizard_data()
+
+ path = os.path.join(os.path.dirname(self._daemon.config.get_wallet_path()), data['wallet_name'])
+
+ super().create_storage(path, data)
+
+ # minimally populate self after create
+ self._password = data['password']
+ self.path = path
+
+ def run_split(self, wallet_path, split_data) -> None:
+ msg = _(
+ "The wallet '{}' contains multiple accounts, which are no longer supported since Electrum 2.7.\n\n"
+ "Do you want to split your wallet into multiple files?").format(wallet_path)
+ if self.question(msg):
+ file_list = WalletDB.split_accounts(wallet_path, split_data)
+ msg = _('Your accounts have been moved to') + ':\n' + '\n'.join(file_list) + '\n\n' + _(
+ 'Do you want to delete the old file') + ':\n' + wallet_path
+ if self.question(msg):
+ os.remove(wallet_path)
+ self.show_warning(_('The file was removed'))
+
+ def is_finalized(self, wizard_data: dict) -> bool:
+ # check decryption of existing wallet and keep wizard open if incorrect.
+
+ if not wizard_data['wallet_exists'] or wizard_data['wallet_is_open']:
+ return True
+
+ wallet_file = wizard_data['wallet_name']
+
+ storage = WalletStorage(wallet_file)
+ assert storage.file_exists(), f"file {wallet_file!r} does not exist"
+ if not storage.is_encrypted_with_user_pw() and not storage.is_encrypted_with_hw_device():
+ return True
+
+ try:
+ storage.decrypt(wizard_data['password'])
+ except InvalidPassword:
+ if storage.is_encrypted_with_hw_device():
+ self.show_message('This hardware device could not decrypt this wallet. Is it the correct one?')
+ else:
+ self.show_message('Invalid password')
+ return False
+
+ return True
+
+ def waiting_dialog(self, task, msg, on_finished=None):
+ dialog = QDialog()
+ label = WWLabel(msg)
+ vbox = QVBoxLayout()
+ vbox.addSpacing(100)
+ label.setMinimumWidth(300)
+ label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ vbox.addWidget(label)
+ vbox.addSpacing(100)
+ dialog.setLayout(vbox)
+ dialog.setModal(True)
+
+ exc = None
+
+ def task_wrap(_task):
+ nonlocal exc
+ try:
+ _task()
+ except Exception as e:
+ exc = e
+
+ t = threading.Thread(target=task_wrap, args=(task,))
+ t.start()
+
+ dialog.show()
+
+ while True:
+ QApplication.processEvents()
+ t.join(1.0/60)
+ if not t.is_alive():
+ break
+
+ dialog.close()
+
+ if exc:
+ raise exc
+
+ if on_finished:
+ on_finished()
+
+
+class WalletWizardComponent(WizardComponent, ABC):
+ # ^ this class only exists to help with typing
+ wizard: QENewWalletWizard
+
+ def __init__(self, parent: QWidget, wizard: QENewWalletWizard, **kwargs):
+ WizardComponent.__init__(self, parent, wizard, **kwargs)
+
+
+class WCWalletName(WalletWizardComponent, Logger):
+ def __init__(self, parent, wizard):
+ WalletWizardComponent.__init__(self, parent, wizard, title=_('Electrum wallet'))
+ Logger.__init__(self)
+
+ path = wizard._path
+
+ if os.path.isdir(path):
+ raise Exception("wallet path cannot point to a directory")
+
+ self.wallet_exists = False
+ self.wallet_is_open = False
+ self.wallet_needs_hw_unlock = False
+
+ hbox = QHBoxLayout()
+ hbox.addWidget(QLabel(_('Wallet') + ':'))
+ self.name_e = QLineEdit()
+ hbox.addWidget(self.name_e)
+ button = QPushButton(_('Choose...'))
+ button_create_new = QPushButton(_('New'))
+ hbox.addWidget(button)
+ hbox.addWidget(button_create_new)
+ self.layout().addLayout(hbox)
+ outside_label = WWLabel('')
+ self.layout().addWidget(outside_label)
+
+ self.layout().addSpacing(50)
+ msg_label = WWLabel('')
+ self.layout().addWidget(msg_label)
+ hbox2 = QHBoxLayout()
+ self.pw_e = PasswordLineEdit('', self)
+ self.pw_e.setFixedWidth(17 * char_width_in_lineedit())
+ pw_label = QLabel(_('Password') + ':')
+ hbox2.addWidget(pw_label)
+ hbox2.addWidget(self.pw_e)
+ hbox2.addStretch()
+ self.layout().addLayout(hbox2)
+ self.layout().addStretch(1)
+
+ temp_storage = None # type: Optional[WalletStorage]
+ datadir_wallet_folder = self.wizard.config.get_datadir_wallet_path()
+
+ def relative_path(path):
+ new_path = path
+ try:
+ if is_subpath(path, datadir_wallet_folder):
+ # below datadir_wallet_path, make relative
+ commonpath = os.path.commonpath([path, datadir_wallet_folder])
+ new_path = os.path.relpath(path, commonpath)
+ except ValueError:
+ pass
+ return new_path
+
+ def on_choose():
+ _path, __ = QFileDialog.getOpenFileName(self, "Select your wallet file", datadir_wallet_folder)
+ if _path:
+ self.name_e.setText(relative_path(_path))
+
+ def on_filename(filename_or_path):
+ # Note: "filename" might contain ".." (etc) and hence sketchy path traversals are possible
+ nonlocal temp_storage
+ temp_storage = None
+ msg = None
+ self.wallet_exists = False
+ self.wallet_is_open = False
+ self.wallet_needs_hw_unlock = False
+ if filename_or_path:
+ # Note: if filename_or_path is a path, os.path.join will leave it unchanged
+ _path = os.path.join(datadir_wallet_folder, filename_or_path)
+ wallet_from_memory = self.wizard._daemon.get_wallet(_path)
+ try:
+ if wallet_from_memory:
+ temp_storage = wallet_from_memory.storage # type: Optional[WalletStorage]
+ self.wallet_is_open = True
+ else:
+ temp_storage = WalletStorage(_path)
+ self.wallet_exists = temp_storage.file_exists()
+ except (StorageReadWriteError, WalletFileException) as e:
+ msg = _('Cannot read file') + f'\n{repr(e)}'
+ except Exception as e:
+ self.logger.exception('')
+ msg = _('Cannot read file') + f'\n{repr(e)}'
+ else:
+ msg = ""
+ self.valid = temp_storage is not None
+ user_needs_to_enter_password = False
+ if temp_storage:
+ if not temp_storage.file_exists():
+ msg = _("This file does not exist.") + '\n' \
+ + _("Press 'Next' to create this wallet, or choose another file.")
+ elif not wallet_from_memory:
+ if temp_storage.is_encrypted_with_user_pw():
+ msg = _("This file is encrypted with a password.")
+ user_needs_to_enter_password = True
+ elif temp_storage.is_encrypted_with_hw_device():
+ msg = _("This file is encrypted using a hardware device.") + '\n' \
+ + _("Press 'Next' to choose device to decrypt.")
+ self.wallet_needs_hw_unlock = True
+ else:
+ msg = _("Press 'Finish' to open this wallet.")
+ else:
+ msg = _("This file is already open in memory.") + "\n" \
+ + _("Press 'Finish' to create/focus window.")
+ if msg is None:
+ msg = _('Cannot read file')
+ if filename_or_path and os.path.isabs(relative_path(_path)):
+ outside_text = _('Note: this wallet file is outside the default wallets folder.')
+ else:
+ outside_text = ''
+ outside_label.setText(outside_text)
+ msg_label.setText(msg)
+ if user_needs_to_enter_password:
+ pw_label.show()
+ self.pw_e.show()
+ if not self.name_e.hasFocus():
+ self.pw_e.setFocus()
+ else:
+ pw_label.hide()
+ self.pw_e.hide()
+ self.on_updated()
+
+ button.clicked.connect(on_choose)
+ button_create_new.clicked.connect(
+ lambda: self.name_e.setText(get_new_wallet_name(datadir_wallet_folder))) # FIXME get_new_wallet_name might raise
+ self.name_e.textChanged.connect(on_filename)
+ self.name_e.setText(relative_path(path))
+
+ def initialFocus(self) -> Optional[QWidget]:
+ return self.pw_e
+
+ def apply(self):
+ if self.wallet_exists:
+ # use full path
+ wallet_folder = self.wizard.config.get_datadir_wallet_path()
+ self.wizard_data['wallet_name'] = os.path.join(wallet_folder, self.name_e.text())
+ else:
+ # FIXME: wizard_data['wallet_name'] is sometimes a full path, sometimes a basename
+ self.wizard_data['wallet_name'] = self.name_e.text()
+ self.wizard_data['wallet_exists'] = self.wallet_exists
+ self.wizard_data['wallet_is_open'] = self.wallet_is_open
+ self.wizard_data['password'] = self.pw_e.text()
+ self.wizard_data['wallet_needs_hw_unlock'] = self.wallet_needs_hw_unlock
+
+
+class WCWalletType(WalletWizardComponent):
+ def __init__(self, parent, wizard):
+ WalletWizardComponent.__init__(self, parent, wizard, title=_('Create new wallet'))
+ message = _('What kind of wallet do you want to create?')
+ wallet_kinds = [
+ ChoiceItem(key='standard', label=_('Standard wallet')),
+ ChoiceItem(key='2fa', label=_('Wallet with two-factor authentication')),
+ ChoiceItem(key='multisig', label=_('Multi-signature wallet')),
+ ChoiceItem(key='imported', label=_('Import Bitcoin addresses or private keys')),
+ ]
+ choices = [c for c in wallet_kinds if c.key in wallet_types]
+
+ self.choice_w = ChoiceWidget(message=message, choices=choices, default_key='standard')
+ self.layout().addWidget(self.choice_w)
+ self.layout().addStretch(1)
+ self._valid = True
+
+ def apply(self):
+ self.wizard_data['wallet_type'] = self.choice_w.selected_key
+
+
+class WCKeystoreType(WalletWizardComponent):
+
+ def __init__(self, parent, wizard):
+ WalletWizardComponent.__init__(self, parent, wizard, title=_('Keystore'))
+ message = _('Do you want to create a new seed, or to restore a wallet using an existing seed?')
+ choices = [
+ ChoiceItem(key='createseed', label=_('Create a new seed')),
+ ChoiceItem(key='haveseed', label=_('I already have a seed')),
+ ChoiceItem(key='masterkey', label=_('Use a master key')),
+ ChoiceItem(key='hardware', label=_('Use a hardware device')),
+ ]
+ self.choice_w = ChoiceWidget(message=message, choices=choices)
+ self.layout().addWidget(self.choice_w)
+ self.layout().addStretch(1)
+ self._valid = True
+
+ def apply(self):
+ self.wizard_data['keystore_type'] = self.choice_w.selected_key
+
+
+class WCExtendKeystore(WalletWizardComponent):
+ def __init__(self, parent, wizard):
+ WalletWizardComponent.__init__(self, parent, wizard, title=_('Keystore'))
+ message = _('What type of signing method do you want to add?')
+ choices = [
+ ChoiceItem(key='haveseed', label=_('Enter seed')),
+ ChoiceItem(key='hardware', label=_('Use a hardware device')),
+ ]
+ self.choice_w = ChoiceWidget(message=message, choices=choices)
+ self.layout().addWidget(self.choice_w)
+ self.layout().addStretch(1)
+ self._valid = True
+
+ def apply(self):
+ self.wizard_data['keystore_type'] = self.choice_w.selected_key
+
+
+class WCCreateSeed(WalletWizardComponent):
+ def __init__(self, parent, wizard):
+ WalletWizardComponent.__init__(self, parent, wizard, title=_('Wallet Seed'))
+ self._busy = True
+ self.seed_type = 'standard' if self.wizard.config.WIZARD_DONT_CREATE_SEGWIT else 'segwit'
+ self.seed_widget = None
+ self.seed = None
+
+ def on_ready(self):
+ if self.wizard_data['wallet_type'] == '2fa':
+ self.seed_type = '2fa_segwit'
+ QTimer.singleShot(1, self.create_seed)
+
+ def apply(self):
+ if self.seed_widget:
+ self.wizard_data['seed'] = self.seed
+ self.wizard_data['seed_type'] = self.seed_type
+ self.wizard_data['seed_extend'] = self.seed_widget.is_ext
+ self.wizard_data['seed_variant'] = 'electrum'
+
+ def create_seed(self):
+ self.busy = True
+ self.seed = mnemonic.Mnemonic('en').make_seed(seed_type=self.seed_type)
+
+ self.seed_widget = SeedWidget(
+ title=_('Your wallet generation seed is:'),
+ seed=self.seed,
+ options=['ext', 'electrum'],
+ msg=True,
+ parent=self,
+ config=self.wizard.config,
+ )
+ self.layout().addWidget(self.seed_widget)
+ self.layout().addStretch(1)
+ self.busy = False
+ self.valid = True
+
+
+class WCConfirmSeed(WalletWizardComponent):
+ def __init__(self, parent, wizard):
+ WalletWizardComponent.__init__(self, parent, wizard, title=_('Confirm Seed'))
+ message = ' '.join([
+ _('Your seed is important!'),
+ _('If you lose your seed, your money will be permanently lost.'),
+ _('To make sure that you have properly saved your seed, please retype it here.')
+ ])
+
+ self.layout().addWidget(WWLabel(message))
+
+ self.seed_widget = SeedWidget(
+ is_seed=lambda x: x == self.wizard_data['seed'],
+ config=self.wizard.config,
+ )
+
+ def seed_valid_changed(valid):
+ self.valid = valid
+
+ self.seed_widget.validChanged.connect(seed_valid_changed)
+ self.layout().addWidget(self.seed_widget)
+
+ wizard.app.clipboard().clear()
+
+ def apply(self):
+ pass
+
+
+class WCEnterExt(WalletWizardComponent, Logger):
+ def __init__(self, parent, wizard):
+ WalletWizardComponent.__init__(self, parent, wizard, title=_('Seed Extension'))
+ Logger.__init__(self)
+
+ message = '\n'.join([
+ _('You may extend your seed with custom words.'),
+ _('Your seed extension must be saved together with your seed.'),
+ ])
+ warning = '\n'.join([
+ _('Note that this is NOT your encryption password.'),
+ _('If you do not know what this is, leave this field empty.'),
+ ])
+
+ self.ext_edit = SeedExtensionEdit(self, message=message, warning=warning)
+ self.ext_edit.textEdited.connect(self.on_text_edited)
+ self.layout().addWidget(self.ext_edit)
+ self.layout().addStretch(1)
+ self.warn_label = IconLabel(reverse=True, hide_if_empty=True)
+ self.warn_label.setIcon(read_QIcon('warning.png'))
+ self.layout().addWidget(self.warn_label)
+
+ def on_ready(self):
+ self.validate()
+
+ def on_text_edited(self, text):
+ # TODO also for cosigners?
+ self.ext_edit.warn_issue4566 = self.wizard_data['keystore_type'] == 'haveseed' and \
+ self.wizard_data['seed_type'] == 'bip39'
+ self.validate()
+
+ def validate(self):
+ self.apply()
+
+ musig_valid, errortext = self.wizard.check_multisig_constraints(self.wizard_data)
+ self.valid = musig_valid
+ self.warn_label.setText(errortext)
+
+ def apply(self):
+ cosigner_data = self.wizard.current_cosigner(self.wizard_data)
+ cosigner_data['seed_extra_words'] = self.ext_edit.text()
+
+
+class WCConfirmExt(WalletWizardComponent):
+ def __init__(self, parent, wizard):
+ WalletWizardComponent.__init__(self, parent, wizard, title=_('Confirm Seed Extension'))
+ message = '\n'.join([
+ _('Your seed extension must be saved together with your seed.'),
+ _('Please type it here.'),
+ ])
+ self.ext_edit = SeedExtensionEdit(self, message=message)
+ self.ext_edit.textEdited.connect(self.on_text_edited)
+ self.layout().addWidget(self.ext_edit)
+ self.layout().addStretch(1)
+
+ def on_ready(self):
+ self.validate()
+
+ def on_text_edited(self, *args):
+ self.validate()
+
+ def validate(self):
+ self.valid = self.ext_edit.text() == self.wizard_data['seed_extra_words']
+
+ def apply(self):
+ pass
+
+
+class WCHaveSeed(WalletWizardComponent, Logger):
+ def __init__(self, parent, wizard):
+ WalletWizardComponent.__init__(self, parent, wizard, title=_('Enter Seed'))
+ Logger.__init__(self)
+
+ self.layout().addWidget(WWLabel(_('Please enter your seed phrase in order to restore your wallet.')))
+ self.warn_label = IconLabel(reverse=True, hide_if_empty=True)
+ self.warn_label.setIcon(read_QIcon('warning.png'))
+
+ self.seed_widget = None
+ self.can_passphrase = True
+
+ def on_ready(self):
+ options = ['ext', 'electrum', 'bip39', 'slip39']
+ if self.wizard_data['wallet_type'] == '2fa':
+ options = ['ext', 'electrum']
+ else:
+ if self.params and 'seed_options' in self.params:
+ options = self.params['seed_options']
+
+ self.seed_widget = SeedWidget(
+ is_seed=self.is_seed,
+ options=options,
+ config=self.wizard.config,
+ )
+
+ def seed_valid_changed(valid):
+ if not valid:
+ self.valid = valid
+ else:
+ self.validate()
+
+ self.seed_widget.validChanged.connect(seed_valid_changed)
+ self.seed_widget.updated.connect(self.validate)
+
+ self.layout().addWidget(self.seed_widget)
+ self.layout().addStretch(1)
+
+ self.layout().addWidget(self.warn_label)
+
+ def is_seed(self, x):
+ # really only used for electrum seeds. bip39 and slip39 are validated in SeedWidget
+ t = mnemonic.calc_seed_type(x)
+ if self.wizard_data['wallet_type'] == 'standard':
+ return mnemonic.is_seed(x) and not mnemonic.is_any_2fa_seed_type(t)
+ elif self.wizard_data['wallet_type'] == '2fa':
+ return mnemonic.is_any_2fa_seed_type(t)
+ else:
+ # multisig? by default, only accept modern non-2fa electrum seeds
+ return t in ['standard', 'segwit']
+
+ def validate(self):
+ # precond: only call when SeedWidget deems seed a valid seed
+ seed = self.seed_widget.get_seed()
+ seed_variant = self.seed_widget.seed_type
+ wallet_type = self.wizard_data['wallet_type']
+ seed_valid, seed_type, validation_message, self.can_passphrase = self.wizard.validate_seed(seed, seed_variant, wallet_type)
+
+ is_cosigner = self.wizard_data['wallet_type'] == 'multisig' and 'multisig_current_cosigner' in self.wizard_data
+
+ if not is_cosigner or not seed_valid:
+ self.valid = seed_valid
+ return
+
+ self.apply()
+ musig_valid, errortext = self.wizard.check_multisig_constraints(self.wizard_data)
+ if not musig_valid:
+ seed_valid = False
+
+ self.warn_label.setText(errortext)
+ self.valid = seed_valid
+
+ def apply(self):
+ cosigner_data = self.wizard.current_cosigner(self.wizard_data)
+
+ cosigner_data['seed'] = self.seed_widget.get_seed()
+ cosigner_data['seed_variant'] = self.seed_widget.seed_type
+ if self.seed_widget.seed_type == 'electrum':
+ cosigner_data['seed_type'] = mnemonic.calc_seed_type(self.seed_widget.get_seed())
+ else:
+ cosigner_data['seed_type'] = self.seed_widget.seed_type
+ cosigner_data['seed_extend'] = self.seed_widget.is_ext if self.can_passphrase else False
+
+
+class WCScriptAndDerivation(WalletWizardComponent, Logger):
+ def __init__(self, parent, wizard):
+ WalletWizardComponent.__init__(self, parent, wizard, title=_('Script type and Derivation path'))
+ Logger.__init__(self)
+
+ self.choice_w = None # type: ChoiceWidget
+ self.derivation_path_edit = None
+
+ self.warn_label = IconLabel(reverse=True, hide_if_empty=True)
+ self.warn_label.setIcon(read_QIcon('warning.png'))
+
+ def on_ready(self):
+ message1 = _('Choose the type of addresses in your wallet.')
+ message2 = ' '.join([
+ _('You can override the suggested derivation path.'),
+ _('If you are not sure what this is, leave this field unchanged.')
+ ])
+ hide_choices = False
+
+ if self.wizard_data['wallet_type'] == 'multisig':
+ choices = [
+ # TODO: nicer to refactor 'standard' to 'p2sh', but backend wallet still uses 'standard'
+ ChoiceItem(key='standard', label='legacy multisig (p2sh)',
+ extra_data=normalize_bip32_derivation("m/45'/0")),
+ ChoiceItem(key='p2wsh-p2sh', label='p2sh-segwit multisig (p2wsh-p2sh)',
+ extra_data=purpose48_derivation(0, xtype='p2wsh-p2sh')),
+ ChoiceItem(key='p2wsh', label='native segwit multisig (p2wsh)',
+ extra_data=purpose48_derivation(0, xtype='p2wsh')),
+ ]
+ if 'multisig_current_cosigner' in self.wizard_data:
+ # get script type of first cosigner
+ ks = self.wizard.keystore_from_data(self.wizard_data['wallet_type'], self.wizard_data)
+ default_choice = xpub_type(ks.get_master_public_key())
+ hide_choices = True
+ else:
+ default_choice = 'p2wsh'
+ else:
+ default_choice = 'p2wpkh'
+ choices = [
+ # TODO: nicer to refactor 'standard' to 'p2pkh', but backend wallet still uses 'standard'
+ ChoiceItem(key='standard', label='legacy (p2pkh)',
+ extra_data=bip44_derivation(0, bip43_purpose=44)),
+ ChoiceItem(key='p2wpkh-p2sh', label='p2sh-segwit (p2wpkh-p2sh)',
+ extra_data=bip44_derivation(0, bip43_purpose=49)),
+ ChoiceItem(key='p2wpkh', label='native segwit (p2wpkh)',
+ extra_data=bip44_derivation(0, bip43_purpose=84)),
+ ]
+
+ if self.wizard_data['wallet_type'] == 'standard' and not self.wizard_data['keystore_type'] == 'hardware':
+ button = QPushButton(_("Detect Existing Accounts"))
+
+ passphrase = self.wizard_data['seed_extra_words'] if self.wizard_data['seed_extend'] else ''
+ if self.wizard_data['seed_variant'] == 'bip39':
+ root_seed = bip39_to_seed(self.wizard_data['seed'], passphrase=passphrase)
+ elif self.wizard_data['seed_variant'] == 'slip39':
+ root_seed = self.wizard_data['seed'].decrypt(passphrase)
+
+ def get_account_xpub(account_path):
+ 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
+
+ def on_account_select(account):
+ script_type = account["script_type"]
+ if script_type == "p2pkh":
+ script_type = "standard"
+ self.choice_w.select(script_type)
+ self.derivation_path_edit.setText(account["derivation_path"])
+
+ button.clicked.connect(lambda: Bip39RecoveryDialog(self, get_account_xpub, on_account_select))
+ self.layout().addWidget(button, alignment=Qt.AlignmentFlag.AlignLeft)
+ self.layout().addWidget(QLabel(_("Or")))
+
+ def on_choice_click(index):
+ self.derivation_path_edit.setText(self.choice_w.selected_item.extra_data)
+ self.choice_w = ChoiceWidget(message=message1, choices=choices, default_key=default_choice)
+ self.choice_w.itemSelected.connect(on_choice_click)
+
+ if not hide_choices:
+ self.layout().addWidget(self.choice_w)
+
+ self.layout().addWidget(WWLabel(message2))
+
+ self.derivation_path_edit = QLineEdit()
+ self.derivation_path_edit.textChanged.connect(self.validate)
+ self.layout().addWidget(self.derivation_path_edit)
+
+ on_choice_click(self.choice_w.selected_index) # set default value for derivation path
+
+ self.layout().addStretch(1)
+ self.layout().addWidget(self.warn_label)
+
+ def validate(self):
+ self.apply()
+
+ cosigner_data = self.wizard.current_cosigner(self.wizard_data)
+ valid = is_bip32_derivation(cosigner_data['derivation_path'])
+
+ if valid:
+ valid, errortext = self.wizard.check_multisig_constraints(self.wizard_data)
+ if not valid:
+ self.logger.error(errortext)
+ self.warn_label.setText(errortext)
+ else:
+ self.warn_label.setText(_('Invalid derivation path'))
+
+ self.valid = valid
+
+ def apply(self):
+ cosigner_data = self.wizard.current_cosigner(self.wizard_data)
+ cosigner_data['script_type'] = self.choice_w.selected_key
+ cosigner_data['derivation_path'] = str(self.derivation_path_edit.text())
+
+
+class WCCosignerKeystore(WalletWizardComponent):
+ def __init__(self, parent, wizard):
+ WalletWizardComponent.__init__(self, parent, wizard)
+
+ message = _('Add a cosigner to your multi-sig wallet')
+ choices = [
+ ChoiceItem(key='masterkey', label=_('Enter cosigner key')),
+ ChoiceItem(key='haveseed', label=_('Enter cosigner seed')),
+ ChoiceItem(key='hardware', label=_('Cosign with hardware device')),
+ ]
+
+ self.choice_w = ChoiceWidget(message=message, choices=choices)
+ self.layout().addWidget(self.choice_w)
+
+ self.cosigner = 0
+ self.participants = 0
+
+ self._valid = True
+
+ def on_ready(self):
+ self.participants = self.wizard_data['multisig_participants']
+ # cosigner index is determined here and put on the wizard_data dict in apply()
+ # as this page is the start for each additional cosigner
+ self.cosigner = 2 + len(self.wizard_data['multisig_cosigner_data'])
+
+ self.wizard_data['multisig_current_cosigner'] = self.cosigner
+ self.title = _("Add Cosigner {}").format(self.wizard_data['multisig_current_cosigner'])
+
+ # different from old wizard: master public key for sharing is now shown on this page
+ self.layout().addSpacing(20)
+ self.layout().addWidget(WWLabel(_('Below is your master public key. Please share it with your cosigners')))
+ seed_widget = SeedWidget(
+ self.wizard_data['multisig_master_pubkey'],
+ icon=False,
+ for_seed_words=False,
+ config=self.wizard.config,
+ )
+ self.layout().addWidget(seed_widget)
+ self.layout().addStretch(1)
+
+ def apply(self):
+ self.wizard_data['cosigner_keystore_type'] = self.choice_w.selected_key
+ self.wizard_data['multisig_current_cosigner'] = self.cosigner
+ self.wizard_data['multisig_cosigner_data'][str(self.cosigner)] = {
+ 'keystore_type': self.choice_w.selected_key
+ }
+
+
+class WCHaveMasterKey(WalletWizardComponent):
+ def __init__(self, parent, wizard):
+ WalletWizardComponent.__init__(self, parent, wizard, title=_('Create keystore from a master key'))
+
+ self.keys_widget = None
+
+ self.message_create = ' '.join([
+ _("To create a watching-only wallet, please enter your master public key (xpub/ypub/zpub)."),
+ _("To create a spending wallet, please enter a master private key (xprv/yprv/zprv).")
+ ])
+ self.message_multisig = ' '.join([
+ _('Please enter your master private key (xprv).'),
+ _('You can also enter a public key (xpub) here, but be aware you will then create a watch-only wallet if all cosigners are added using public keys'),
+ ])
+ self.message_cosign = ' '.join([
+ _('Please enter the master public key (xpub) of your cosigner.'),
+ _('Enter their master private key (xprv) if you want to be able to sign for them.')
+ ])
+
+ self.header_layout = QHBoxLayout()
+ self.label = WWLabel()
+ self.label.setMinimumWidth(400)
+ self.header_layout.addWidget(self.label)
+
+ self.warn_label = IconLabel(reverse=True, hide_if_empty=True)
+ self.warn_label.setIcon(read_QIcon('warning.png'))
+
+ def on_ready(self):
+ if self.wizard_data['wallet_type'] == 'standard':
+ self.label.setText(self.message_create)
+
+ def is_valid(x) -> bool:
+ self.apply()
+ key_valid, message = self.wizard.validate_master_key(x, self.wizard_data['wallet_type'])
+ self.warn_label.setText(message)
+ return key_valid
+ elif self.wizard_data['wallet_type'] == 'multisig':
+ if 'multisig_current_cosigner' in self.wizard_data:
+ self.title = _("Add Cosigner {}").format(self.wizard_data['multisig_current_cosigner'])
+ self.label.setText(self.message_cosign)
+ else:
+ self.label.setText(self.message_multisig)
+
+ def is_valid(x) -> bool:
+ self.apply()
+ key_valid, message = self.wizard.validate_master_key(x, self.wizard_data['wallet_type'])
+ if not key_valid:
+ self.warn_label.setText(message)
+ return False
+ musig_valid, errortext = self.wizard.check_multisig_constraints(self.wizard_data)
+ self.warn_label.setText(errortext)
+ if not musig_valid:
+ return False
+ return True
+ else:
+ raise Exception(f"unexpected wallet type: {self.wizard_data['wallet_type']}")
+
+ self.keys_widget = KeysWidget(parent=self, header_layout=self.header_layout, is_valid=is_valid,
+ allow_multi=False, config=self.wizard.config)
+
+ def key_valid_changed(valid):
+ self.valid = valid
+
+ self.keys_widget.validChanged.connect(key_valid_changed)
+
+ self.layout().addWidget(self.keys_widget)
+ self.layout().addStretch()
+ self.layout().addWidget(self.warn_label)
+
+ def apply(self):
+ text = self.keys_widget.get_text()
+ cosigner_data = self.wizard.current_cosigner(self.wizard_data)
+ cosigner_data['master_key'] = text
+
+
+class WCMultisig(WalletWizardComponent):
+ def __init__(self, parent, wizard):
+ WalletWizardComponent.__init__(self, parent, wizard, title=_('Multi-Signature Wallet'))
+
+ 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)
+
+ 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.'))
+
+ cw = CosignWidget(2, 2)
+ m_label = QLabel()
+ n_label = QLabel()
+
+ m_edit = QSlider(Qt.Orientation.Horizontal, self)
+ m_edit.setMinimum(1)
+ m_edit.setMaximum(2)
+ m_edit.setValue(2)
+ m_edit.valueChanged.connect(on_m)
+ on_m(m_edit.value())
+
+ n_edit = QSlider(Qt.Orientation.Horizontal, self)
+ n_edit.setMinimum(2)
+ n_edit.setMaximum(15)
+ n_edit.setValue(2)
+ n_edit.valueChanged.connect(on_n)
+ on_n(n_edit.value())
+
+ grid = QGridLayout()
+ grid.addWidget(n_label, 0, 0)
+ grid.addWidget(n_edit, 0, 1)
+ grid.addWidget(m_label, 1, 0)
+ grid.addWidget(m_edit, 1, 1)
+
+ self.layout().addWidget(cw)
+ self.layout().addWidget(WWLabel(_('Choose the number of signatures needed to unlock funds in your wallet:')))
+ self.layout().addLayout(grid)
+ self.layout().addSpacing(2 * char_width_in_lineedit())
+ self.layout().addWidget(backup_warning_label)
+ self.layout().addStretch(1)
+
+ self.n_edit = n_edit
+ self.m_edit = m_edit
+
+ self._valid = True
+
+ def apply(self):
+ self.wizard_data['multisig_participants'] = int(self.n_edit.value())
+ self.wizard_data['multisig_signatures'] = int(self.m_edit.value())
+ self.wizard_data['multisig_cosigner_data'] = {}
+
+
+class WCImport(WalletWizardComponent):
+ def __init__(self, parent, wizard):
+ WalletWizardComponent.__init__(self, parent, wizard, title=_('Import Bitcoin Addresses or Private Keys'))
+ message = _(
+ 'Enter a list of Bitcoin addresses (this will create a watching-only wallet), or a list of private keys.')
+ header_layout = QHBoxLayout()
+ label = WWLabel(message)
+ label.setMinimumWidth(400)
+ header_layout.addWidget(label)
+ header_layout.addWidget(InfoButton(WIF_HELP_TEXT), alignment=Qt.AlignmentFlag.AlignRight)
+
+ def is_valid(x) -> bool:
+ return keystore.is_address_list(x) or keystore.is_private_key_list(x, raise_on_error=True)
+
+ self.keys_widget = KeysWidget(header_layout=header_layout, is_valid=is_valid,
+ allow_multi=True, config=self.wizard.config)
+
+ def key_valid_changed(valid):
+ self.valid = valid
+
+ self.keys_widget.validChanged.connect(key_valid_changed)
+ self.layout().addWidget(self.keys_widget)
+
+ def apply(self):
+ text = self.keys_widget.get_text()
+ if keystore.is_address_list(text):
+ self.wizard_data['address_list'] = text
+ elif keystore.is_private_key_list(text):
+ self.wizard_data['private_key_list'] = text
+
+
+class WCWalletPassword(WalletWizardComponent):
+
+ def __init__(self, parent, wizard):
+ WalletWizardComponent.__init__(self, parent, wizard, title=_('Wallet Password'))
+ # TODO: PasswordLayout assumes a button, refactor PasswordLayout
+ # for now, fake next_button.setEnabled
+ class Hack:
+ def setEnabled(self2, b):
+ self.valid = b
+ self.next_button = Hack()
+ self.pw_layout = PasswordLayout(
+ msg=MSG_ENTER_PASSWORD,
+ kind=PW_NEW,
+ OK_button=self.next_button,
+ )
+ self.layout().addLayout(self.pw_layout.layout())
+ self.layout().addStretch(1)
+
+ def initialFocus(self) -> Optional[QWidget]:
+ return self.pw_layout.new_pw
+
+ def apply(self):
+ self.wizard_data['password'] = self.pw_layout.new_password()
+ self.wizard_data['encrypt'] = True
+
+
+class SeedExtensionEdit(QWidget):
+ def __init__(self, parent, *, message: str = None, warning: str = None, warn_issue4566: bool = False):
+ super().__init__(parent)
+
+ self.warn_issue4566 = warn_issue4566
+
+ layout = QVBoxLayout()
+ self.setLayout(layout)
+
+ if message:
+ layout.addWidget(WWLabel(message))
+
+ self.line = QLineEdit()
+ layout.addWidget(self.line)
+
+ def f(text):
+ if self.warn_issue4566:
+ text_whitespace_normalised = ' '.join(text.split())
+ warn_issue4566_label.setVisible(text != text_whitespace_normalised)
+ self.line.textEdited.connect(f)
+
+ if warning:
+ layout.addWidget(WWLabel(warning))
+
+ warn_issue4566_label = WWLabel(MSG_PASSPHRASE_WARN_ISSUE4566)
+ warn_issue4566_label.setVisible(False)
+ layout.addWidget(warn_issue4566_label)
+
+ # expose textEdited signal and text() func to widget
+ self.textEdited = self.line.textEdited
+ self.text = self.line.text
+
+
+class CosignWidget(QWidget):
+ def __init__(self, m, n):
+ QWidget.__init__(self)
+ self.size = max(120, 9 * font_height())
+ self.R = QRect(0, 0, self.size, self.size)
+ self.setGeometry(self.R)
+ self.setMinimumHeight(self.size)
+ self.setMaximumHeight(self.size)
+ self.m = m
+ self.n = n
+
+ def set_n(self, n):
+ self.n = n
+ self.update()
+
+ def set_m(self, m):
+ self.m = m
+ self.update()
+
+ def paintEvent(self, event):
+ bgcolor = self.palette().color(QPalette.ColorRole.Window)
+ pen = QPen(bgcolor, 7, Qt.PenStyle.SolidLine)
+ qp = QPainter()
+ qp.begin(self)
+ qp.setPen(pen)
+ qp.setRenderHint(QPainter.RenderHint.Antialiasing)
+ qp.setBrush(Qt.GlobalColor.gray)
+ for i in range(self.n):
+ alpha = int(16 * 360 * i/self.n)
+ alpha2 = int(16 * 360 * 1/self.n)
+ qp.setBrush(Qt.GlobalColor.green if i < self.m else Qt.GlobalColor.gray)
+ qp.drawPie(self.R, alpha, alpha2)
+ qp.end()
+
+
+class WCChooseHWDevice(WalletWizardComponent, Logger):
+ scanFailed = pyqtSignal([str, str], arguments=['code', 'message'])
+ scanComplete = pyqtSignal()
+
+ def __init__(self, parent, wizard):
+ WalletWizardComponent.__init__(self, parent, wizard, title=_('Choose Hardware Device'))
+ Logger.__init__(self)
+ self.scanFailed.connect(self.on_scan_failed)
+ self.scanComplete.connect(self.on_scan_complete)
+ self.plugins = wizard.plugins
+ self.config = wizard.config
+
+ self.error_l = WWLabel()
+ self.error_l.setVisible(False)
+
+ self.device_list = QWidget()
+ self.device_list_layout = QVBoxLayout()
+ self.device_list.setLayout(self.device_list_layout)
+ self.choice_w = None # type: ChoiceWidget
+
+ self.rescan_button = QPushButton(_('Rescan devices'))
+ self.rescan_button.clicked.connect(self.on_rescan)
+
+ self.add_plugin_button = QPushButton(_('Add plugin'))
+ self.add_plugin_button.clicked.connect(self.on_add_plugin)
+
+ hbox = QHBoxLayout()
+ hbox.addStretch(1)
+ hbox.addWidget(self.rescan_button)
+ hbox.addWidget(self.add_plugin_button)
+ hbox.addStretch(1)
+
+ self.layout().addWidget(self.error_l)
+ self.layout().addWidget(self.device_list)
+ self.layout().addStretch(1)
+ self.layout().addLayout(hbox)
+ self.layout().addStretch(1)
+
+ def on_ready(self):
+ self.scan_devices()
+
+ def on_rescan(self):
+ self.scan_devices()
+
+ def on_add_plugin(self):
+ d = PluginsDialog(self.config, self.plugins)
+ d.exec()
+ self.scan_devices()
+
+ def on_scan_failed(self, code, message):
+ self.error_l.setText(message)
+ self.error_l.setVisible(True)
+ self.device_list.setVisible(False)
+
+ self.valid = False
+
+ def on_scan_complete(self):
+ self.error_l.setVisible(False)
+ self.device_list.setVisible(True)
+
+ choices = [] # type: List[ChoiceItem]
+ for name, info in self.devices:
+ state = _("initialized") if info.initialized else _("wiped")
+ label = info.label or _("An unnamed {}").format(name)
+ try:
+ transport_str = info.device.transport_ui_string[:20]
+ except Exception:
+ transport_str = 'unknown transport'
+ descr = f"{label} [{info.model_name or name}, {state}, {transport_str}]"
+ choices.append(ChoiceItem(key=(name, info), label=descr))
+ msg = _('Select a device') + ':'
+
+ if self.choice_w:
+ self.device_list_layout.removeWidget(self.choice_w)
+
+ self.choice_w = ChoiceWidget(message=msg, choices=choices)
+ self.device_list_layout.addWidget(self.choice_w)
+
+ self.valid = True
+
+ if self.valid:
+ self.wizard.next_button.setFocus()
+ else:
+ self.rescan_button.setFocus()
+
+ def scan_devices(self):
+ self.valid = False
+ self.busy_msg = _('Scanning devices...')
+ self.busy = True
+
+ def scan_task():
+ # check available plugins
+ supported_plugins = self.plugins.get_hardware_support()
+ devices = [] # type: List[Tuple[str, DeviceInfo]]
+ devmgr = self.plugins.device_manager
+ debug_msg = ''
+
+ def failed_getting_device_infos(name, e):
+ nonlocal debug_msg
+ err_str_oneline = ' // '.join(str(e).splitlines())
+ self.logger.warning(f'error getting device infos for {name}: {err_str_oneline}')
+ _indented_error_msg = ' '.join([''] + str(e).splitlines(keepends=True))
+ debug_msg += f' {name}: (error getting device infos)\n{_indented_error_msg}\n'
+
+ # scan devices
+ try:
+ # scanned_devices = self.run_task_without_blocking_gui(task=devmgr.scan_devices,
+ # msg=_("Scanning devices..."))
+ scanned_devices = devmgr.scan_devices()
+ except BaseException as e:
+ self.logger.info('error scanning devices: {}'.format(repr(e)))
+ debug_msg = ' {}:\n {}'.format(_('Error scanning devices'), e)
+ else:
+ for splugin in supported_plugins:
+ name, plugin = splugin.name, splugin.plugin
+ # plugin init errored?
+ if not plugin:
+ e = splugin.exception
+ indented_error_msg = ' '.join([''] + str(e).splitlines(keepends=True))
+ debug_msg += f' {name}: (error during plugin init)\n'
+ debug_msg += ' {}\n'.format(_('You might have an incompatible library.'))
+ debug_msg += f'{indented_error_msg}\n'
+ continue
+ # see if plugin recognizes 'scanned_devices'
+ try:
+ # FIXME: side-effect: this sets client.handler
+ device_infos = devmgr.list_pairable_device_infos(
+ handler=None, plugin=plugin, devices=scanned_devices, include_failing_clients=True)
+ except HardwarePluginLibraryUnavailable as e:
+ failed_getting_device_infos(name, e)
+ continue
+ except BaseException as e:
+ self.logger.exception('')
+ failed_getting_device_infos(name, e)
+ continue
+ device_infos_failing = list(filter(lambda di: di.exception is not None, device_infos))
+ for di in device_infos_failing:
+ failed_getting_device_infos(name, di.exception)
+ device_infos_working = list(filter(lambda di: di.exception is None, device_infos))
+ devices += list(map(lambda x: (name, x), device_infos_working))
+ if not debug_msg:
+ debug_msg = ' {}'.format(_('No exceptions encountered.'))
+ if not devices:
+ msg = (_('No hardware device detected.') + '\n\n')
+ if sys.platform == 'win32':
+ msg += _('If your device is not detected on Windows, go to "Settings", "Devices", "Connected devices", '
+ 'and do "Remove device". Then, plug your device again.') + '\n'
+ msg += _('While this is less than ideal, it might help if you run Electrum as Administrator.') + '\n'
+ else:
+ msg += _('On Linux, you might have to add a new permission to your udev rules.') + '\n'
+ msg += '\n\n'
+ msg += _('Debug message') + '\n' + debug_msg
+
+ self.scanFailed.emit('no_devices', msg)
+ self.busy = False
+ return
+
+ # select device
+ self.devices = devices
+ self.scanComplete.emit()
+ self.busy = False
+
+ t = threading.Thread(target=scan_task, daemon=True)
+ t.start()
+
+ def apply(self):
+ if self.choice_w:
+ cosigner_data = self.wizard.current_cosigner(self.wizard_data)
+ cosigner_data['hardware_device'] = self.choice_w.selected_key
+
+
+class WCWalletPasswordHardware(WalletWizardComponent):
+
+ def __init__(self, parent, wizard):
+ WalletWizardComponent.__init__(self, parent, wizard, title=_('Encrypt using hardware'))
+ self.plugins = wizard.plugins
+ # TODO: PasswordLayout assumes a button, refactor PasswordLayout
+ # for now, fake next_button.setEnabled
+ class Hack:
+ def setEnabled(self2, b):
+ self.valid = b
+ self.next_button = Hack()
+ self.playout = PasswordLayoutForHW(
+ MSG_HW_STORAGE_ENCRYPTION,
+ kind=PW_NEW,
+ OK_button=self.next_button,
+ )
+ self.layout().addLayout(self.playout.layout())
+ self.layout().addStretch(1)
+
+ self._hw_password = None # type: Optional[str]
+ self._valid = False
+
+ def on_ready(self):
+ _name, info = self.wizard_data['hardware_device']
+ device_id = info.device.id_
+ client = self.plugins.device_manager.client_by_id(device_id, scan_now=False)
+ if client is None:
+ self.valid = False
+ self.error = _("Client for hardware device was unpaired.")
+ return
+
+ def retrieve_password_task():
+ try:
+ self._hw_password = client.get_password_for_storage_encryption()
+ self.valid = True
+ except UserFacingException as e:
+ self.error = str(e)
+ self.valid = False
+ finally:
+ self.busy = False
+
+ self.busy = True
+ t = threading.Thread(target=retrieve_password_task, daemon=True)
+ t.start()
+
+ def apply(self):
+ if not self.valid:
+ return
+ self.wizard_data['encrypt'] = True
+ if self.playout.should_encrypt_storage_with_xpub():
+ self.wizard_data['xpub_encrypt'] = True
+ assert self._hw_password
+ self.wizard_data['password'] = self._hw_password
+ else:
+ self.wizard_data['xpub_encrypt'] = False
+ self.wizard_data['password'] = self.playout.new_password()
+
+
+class WCHWUnlock(WalletWizardComponent, Logger):
+ def __init__(self, parent, wizard):
+ WalletWizardComponent.__init__(self, parent, wizard, title=_('Unlocking hardware'))
+ Logger.__init__(self)
+ self.plugins = wizard.plugins
+ self.plugin = None
+ self._busy = True
+ self.password = None
+
+ ok_icon = QLabel()
+ ok_icon.setPixmap(QPixmap(icon_path('confirmed.png')).scaledToWidth(48, mode=Qt.TransformationMode.SmoothTransformation))
+ ok_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.ok_l = WWLabel(_('Hardware successfully unlocked'))
+ self.ok_l.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.layout().addStretch(1)
+ self.layout().addWidget(ok_icon)
+ self.layout().addWidget(self.ok_l)
+ self.layout().addStretch(1)
+
+ def on_ready(self):
+ _name, _info = self.wizard_data['hardware_device']
+ self.plugin = self.plugins.get_plugin(_info.plugin_name)
+ self.title = _('Unlocking {} ({})').format(_info.model_name, _info.label)
+
+ device_id = _info.device.id_
+ client = self.plugins.device_manager.client_by_id(device_id, scan_now=False)
+ if client is None:
+ self.error = _("Client for hardware device was unpaired.")
+ self.busy = False
+ self.validate()
+ return
+ client.handler = self.plugin.create_handler(self.wizard)
+
+ def unlock_task(client):
+ try:
+ self.password = client.get_password_for_storage_encryption()
+ except UserCancelled as e:
+ self.error = repr(e)
+ except Exception as e:
+ self.error = repr(e) # TODO: handle user interaction exceptions (e.g. invalid pin) more gracefully
+ self.logger.exception(repr(e))
+ self.busy = False
+ self.validate()
+
+ t = threading.Thread(target=unlock_task, args=(client,), daemon=True)
+ t.start()
+
+ def validate(self):
+ self.valid = False
+ if self.password and not self.error:
+ if not self.check_hw_decrypt():
+ self.error = _('This hardware device could not decrypt this wallet. Is it the correct one?')
+ else:
+ self.apply()
+ self.valid = True
+
+ if self.valid:
+ self.wizard.requestNext.emit() # via signal, so it triggers Next/Finish on GUI thread after on_updated()
+
+ def check_hw_decrypt(self):
+ wallet_file = self.wizard_data['wallet_name']
+
+ storage = WalletStorage(wallet_file)
+ if not storage.is_encrypted_with_hw_device():
+ return True
+
+ try:
+ storage.decrypt(self.password)
+ except InvalidPassword:
+ return False
+ return True
+
+ def apply(self):
+ if self.valid:
+ self.wizard_data['password'] = self.password
+
+
+class WCHWXPub(WalletWizardComponent, Logger):
+ def __init__(self, parent, wizard):
+ WalletWizardComponent.__init__(self, parent, wizard, title=_('Retrieving extended public key from hardware'))
+ Logger.__init__(self)
+ self.plugins = wizard.plugins
+ self.plugin = None
+ self._busy = True
+
+ self.xpub = None
+ self.root_fingerprint = None
+ self.label = None
+ self.soft_device_id = None
+
+ ok_icon = QLabel()
+ ok_icon.setPixmap(QPixmap(icon_path('confirmed.png')).scaledToWidth(48, mode=Qt.TransformationMode.SmoothTransformation))
+ ok_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.ok_l = WWLabel(_('Hardware keystore added to wallet'))
+ self.ok_l.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.layout().addStretch(1)
+ self.layout().addWidget(ok_icon)
+ self.layout().addWidget(self.ok_l)
+ self.layout().addStretch(1)
+
+ def on_ready(self):
+ cosigner_data = self.wizard.current_cosigner(self.wizard_data)
+ _name, _info = cosigner_data['hardware_device']
+ self.plugin = self.plugins.get_plugin(_info.plugin_name)
+ self.title = _('Retrieving extended public key from {} ({})').format(_info.model_name, _info.label)
+
+ device_id = _info.device.id_
+ client = self.plugins.device_manager.client_by_id(device_id, scan_now=False)
+ if client is None:
+ self.error = _("Client for hardware device was unpaired.")
+ self.busy = False
+ self.validate()
+ return
+ if not client.handler:
+ client.handler = self.plugin.create_handler(self.wizard)
+
+ xtype = cosigner_data['script_type']
+ derivation = cosigner_data['derivation_path']
+
+ def get_xpub_task(_client, _derivation, _xtype):
+ try:
+ self.xpub = self.get_xpub_from_client(_client, _derivation, _xtype)
+ self.root_fingerprint = _client.request_root_fingerprint_from_device()
+ self.label = _client.label()
+ self.soft_device_id = _client.get_soft_device_id()
+ except UserFacingException as e:
+ self.error = str(e)
+ self.logger.error(repr(e))
+ except Exception as e:
+ self.error = repr(e) # TODO: handle user interaction exceptions (e.g. invalid pin) more gracefully
+ self.logger.exception(repr(e))
+ if self.xpub:
+ self.logger.debug(f'Done retrieve xpub: {self.xpub[:10]}...{self.xpub[-5:]}')
+ self.busy = False
+ self.validate()
+
+ t = threading.Thread(target=get_xpub_task, args=(client, derivation, xtype), daemon=True)
+ t.start()
+
+ def get_xpub_from_client(self, client, derivation, xtype): # override for HWW specific client if needed
+ cosigner_data = self.wizard.current_cosigner(self.wizard_data)
+ _name, _info = cosigner_data['hardware_device']
+ if xtype not in self.plugin.SUPPORTED_XTYPES:
+ raise ScriptTypeNotSupported(_('This type of script is not supported with {}').format(_info.model_name))
+ return client.get_xpub(derivation, xtype)
+
+ def validate(self):
+ if self.xpub and not self.error:
+ self.apply()
+ valid, error = self.wizard.check_multisig_constraints(self.wizard_data)
+ if not valid:
+ self.error = '\n'.join([
+ _('Could not add hardware keystore to wallet'),
+ error
+ ])
+ self.valid = valid
+ else:
+ self.valid = False
+
+ if self.valid:
+ self.wizard.requestNext.emit() # via signal, so it triggers Next/Finish on GUI thread after on_updated()
+
+ def apply(self):
+ cosigner_data = self.wizard.current_cosigner(self.wizard_data)
+ _name, _info = cosigner_data['hardware_device']
+ cosigner_data['hw_type'] = _info.plugin_name
+ cosigner_data['master_key'] = self.xpub
+ cosigner_data['root_fingerprint'] = self.root_fingerprint
+ cosigner_data['label'] = self.label
+ cosigner_data['soft_device_id'] = self.soft_device_id
+
+
+class WCHWUninitialized(WalletWizardComponent):
+ def __init__(self, parent, wizard):
+ WalletWizardComponent.__init__(self, parent, wizard, title=_('Hardware not initialized'))
+
+ def on_ready(self):
+ cosigner_data = self.wizard.current_cosigner(self.wizard_data)
+ _name, _info = cosigner_data['hardware_device']
+ w_icon = QLabel()
+ w_icon.setPixmap(QPixmap(icon_path('warning.png')).scaledToWidth(48, mode=Qt.TransformationMode.SmoothTransformation))
+ w_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ label = WWLabel(_('This {} is not initialized. Use manufacturer tooling to initialize the device.').format(_info.model_name))
+ label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.layout().addStretch(1)
+ self.layout().addWidget(w_icon)
+ self.layout().addWidget(label)
+ self.layout().addStretch(1)
+
+ def apply(self):
+ pass
diff --git a/electrum/gui/qt/wizard/wizard.py b/electrum/gui/qt/wizard/wizard.py
new file mode 100644
index 000000000000..eb0e74d8667f
--- /dev/null
+++ b/electrum/gui/qt/wizard/wizard.py
@@ -0,0 +1,324 @@
+import copy
+import threading
+from abc import abstractmethod
+from typing import TYPE_CHECKING, Optional
+
+from PyQt6.QtCore import Qt, QTimer, pyqtSignal, pyqtSlot, QSize, QMetaObject
+from PyQt6.QtGui import QPixmap
+from PyQt6.QtWidgets import (QDialog, QPushButton, QWidget, QLabel, QVBoxLayout, QScrollArea,
+ QHBoxLayout, QLayout)
+
+from electrum.i18n import _
+from electrum.logging import get_logger
+from electrum.gui.qt.util import Buttons, icon_path, MessageBoxMixin, WWLabel, ResizableStackedWidget, AbstractQWidget
+
+if TYPE_CHECKING:
+ from electrum.simple_config import SimpleConfig
+ from electrum.gui.qt import QElectrumApplication
+ from electrum.wizard import WizardViewState
+
+
+class QEAbstractWizard(QDialog, MessageBoxMixin):
+ """ Concrete subclasses of QEAbstractWizard must also inherit from a concrete AbstractWizard subclass.
+ QEAbstractWizard forms the base for all QtWidgets GUI based wizards, while AbstractWizard defines
+ the base for non-gui wizard flow navigation functionality.
+ """
+ _logger = get_logger(__name__)
+
+ requestNext = pyqtSignal()
+ requestPrev = pyqtSignal()
+
+ def __init__(self, config: 'SimpleConfig', app: 'QElectrumApplication', *, start_viewstate: 'WizardViewState' = None):
+ QDialog.__init__(self, None)
+ self.app = app
+ self.config = config
+
+ # compat
+ self.gui_thread = threading.current_thread()
+
+ self.setMinimumSize(600, 400)
+
+ self.title = QLabel()
+ self.window_title = ''
+ self.finish_label = _('Finish')
+
+ self.main_widget = ResizableStackedWidget(self)
+
+ self.back_button = QPushButton(_("Back"), self)
+ self.back_button.clicked.connect(self.on_back_button_clicked)
+ self.back_button.setEnabled(False)
+ self.back_button.setDefault(False)
+ self.back_button.setAutoDefault(False)
+ self.next_button = QPushButton(_("Next"), self)
+ self.next_button.clicked.connect(self.on_next_button_clicked)
+ self.next_button.setEnabled(False)
+ self.next_button.setDefault(True)
+ self.next_button.setAutoDefault(True)
+ self.requestPrev.connect(self.on_back_button_clicked)
+ self.requestNext.connect(self.on_next_button_clicked)
+ self.logo = QLabel()
+
+ please_wait_layout = QVBoxLayout()
+ please_wait_layout.addStretch(1)
+ self.please_wait_l = QLabel(_("Please wait..."))
+ self.please_wait_l.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ please_wait_layout.addWidget(self.please_wait_l)
+ please_wait_layout.addStretch(1)
+ self.please_wait = QWidget()
+ self.please_wait.setVisible(False)
+ self.please_wait.setLayout(please_wait_layout)
+
+ error_layout = QVBoxLayout()
+ error_layout.addStretch(1)
+ error_icon = QLabel()
+ error_icon.setPixmap(QPixmap(icon_path('warning.png')).scaledToWidth(48, mode=Qt.TransformationMode.SmoothTransformation))
+ error_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ error_layout.addWidget(error_icon)
+ self.error_msg = WWLabel()
+ self.error_msg.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ error_layout.addWidget(self.error_msg)
+ error_layout.addStretch(1)
+ self.error = QWidget()
+ self.error.setVisible(False)
+ self.error.setLayout(error_layout)
+
+ outer_vbox = QVBoxLayout(self)
+ inner_vbox = QVBoxLayout()
+ inner_vbox.addWidget(self.title)
+ inner_vbox.addWidget(self.main_widget)
+ inner_vbox.addWidget(self.please_wait)
+ inner_vbox.addWidget(self.error)
+
+ scroll_widget = QWidget()
+ scroll_widget.setLayout(inner_vbox)
+ scroll = QScrollArea()
+ scroll.setFocusPolicy(Qt.FocusPolicy.NoFocus)
+ scroll.setWidget(scroll_widget)
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ scroll.setWidgetResizable(True)
+ icon_vbox = QVBoxLayout()
+ icon_vbox.addWidget(self.logo)
+ icon_vbox.addStretch(1)
+ hbox = QHBoxLayout()
+ hbox.addLayout(icon_vbox)
+ hbox.addSpacing(5)
+ hbox.addWidget(scroll)
+ hbox.setStretchFactor(scroll, 1)
+ outer_vbox.addLayout(hbox)
+ outer_vbox.addLayout(Buttons(self.back_button, self.next_button))
+
+ self.setTabOrder(self.back_button, self.next_button)
+
+ self.icon_filename = None
+ self.set_icon('electrum.png')
+
+ self.start_viewstate = start_viewstate
+
+ self.show()
+ self.raise_()
+
+ QMetaObject.invokeMethod(self, 'strt', Qt.ConnectionType.QueuedConnection) # call strt after subclass constructor(s)
+
+ def sizeHint(self) -> QSize:
+ return QSize(600, 400)
+
+ @pyqtSlot()
+ def strt(self):
+ viewstate = self.start_wizard(start_viewstate=self.start_viewstate)
+ self.load_next_component(viewstate.view, viewstate.wizard_data, viewstate.params)
+ self.set_default_focus()
+
+ # TODO: re-test if needed on macOS
+ self.refresh_gui() # Need for QT on MacOSX. Lame.
+
+ def refresh_gui(self):
+ # For some reason, to refresh the GUI this needs to be called twice
+ self.app.processEvents()
+ self.app.processEvents()
+
+ def load_next_component(self, view, wdata=None, params=None):
+ if wdata is None:
+ wdata = {}
+ if params is None:
+ params = {}
+
+ comp = self.view_to_component(view)
+ try:
+ self._logger.debug(f'load_next_component: {comp!r}')
+ page = comp(self.main_widget, self)
+ except Exception as e:
+ self._logger.error(f'not a class: {comp!r}')
+ raise e
+ page.wizard_data = copy.deepcopy(wdata)
+ page.params = params
+ page.on_ready() # call before component emits any signals
+
+ page.updated.connect(self.on_page_updated)
+
+ # add to stack and update wizard
+ page.apply()
+ self.main_widget.setCurrentIndex(self.main_widget.addWidget(page))
+ self.update()
+
+ @pyqtSlot(object)
+ def on_page_updated(self, page):
+ page.apply()
+ if page == self.main_widget.currentWidget():
+ self.update()
+
+ def set_icon(self, filename):
+ prior_filename, self.icon_filename = self.icon_filename, filename
+ self.logo.setPixmap(QPixmap(icon_path(filename))
+ .scaledToWidth(60, mode=Qt.TransformationMode.SmoothTransformation))
+ return prior_filename
+
+ def set_default_focus(self):
+ page = self.main_widget.currentWidget()
+ control = page.initialFocus()
+ if control and control.isVisible() and control.isEnabled():
+ control.setFocus()
+ else:
+ self.next_button.setFocus()
+
+ def can_go_back(self) -> bool:
+ return len(self._stack) > 0
+
+ def update(self):
+ page = self.main_widget.currentWidget()
+ self.setWindowTitle(page.wizard_title if page.wizard_title else self.window_title)
+ self.title.setText(f'{page.title}' if page.title else '')
+ self.back_button.setText(_('Back') if self.can_go_back() else _('Cancel'))
+ self.back_button.setEnabled(not page.busy)
+ self.next_button.setText(_('Next') if not self.is_last(page.wizard_data) else self.finish_label)
+ self.next_button.setEnabled(not page.busy and page.valid)
+ self.main_widget.setVisible(not page.busy and not bool(page.error))
+ self.please_wait.setVisible(page.busy)
+ self.please_wait_l.setText(page.busy_msg if page.busy_msg else _("Please wait..."))
+ self.error_msg.setText(str(page.error))
+ self.error.setVisible(not page.busy and bool(page.error))
+ icon = page.params.get('icon', icon_path('electrum.png'))
+ if icon:
+ if icon != self.icon_filename:
+ self.set_icon(icon)
+ self.logo.setVisible(True)
+ else:
+ self.logo.setVisible(False)
+
+ def on_back_button_clicked(self):
+ if self.can_go_back():
+ self.prev()
+ widget = self.main_widget.currentWidget()
+ self.main_widget.removeWidget(widget)
+ widget.deleteLater()
+ self.update()
+ else:
+ self.close()
+
+ def on_next_button_clicked(self):
+ page = self.main_widget.currentWidget()
+ page.apply()
+ wd = page.wizard_data.copy()
+ if self.is_last(wd):
+ self.submit(wd)
+ if self.is_finalized(wd):
+ self.accept()
+ else:
+ self.prev() # rollback the submit above
+ else:
+ view = self.submit(wd)
+ try:
+ self.load_next_component(view.view, view.wizard_data, view.params)
+ self.set_default_focus()
+ except Exception as e:
+ self.prev() # rollback the submit above
+ raise e
+
+ def start_wizard(self, *, start_viewstate: Optional['WizardViewState'] = None) -> 'WizardViewState':
+ self.start(start_viewstate=start_viewstate)
+ return self._current
+
+ def view_to_component(self, view) -> QWidget:
+ return self.navmap[view]['gui']
+
+ def submit(self, wizard_data) -> 'WizardViewState':
+ wdata = wizard_data.copy()
+ view = self.resolve_next(self._current.view, wdata)
+ return view
+
+ def prev(self) -> dict:
+ viewstate = self.resolve_prev()
+ return viewstate.wizard_data
+
+ def is_last(self, wizard_data: dict) -> bool:
+ wdata = wizard_data.copy()
+ return self.is_last_view(self._current.view, wdata)
+
+ def is_finalized(self, wizard_data: dict) -> bool:
+ ''' Final check before closing the wizard. '''
+ return True
+
+
+class WizardComponent(AbstractQWidget):
+ updated = pyqtSignal(object)
+
+ def __init__(self, parent: QWidget, wizard: QEAbstractWizard, *, title: str = None, layout: QLayout = None):
+ super().__init__(parent)
+ self.setLayout(layout if layout else QVBoxLayout(self))
+ self.wizard_data = {}
+ self.title = title if title is not None else 'No title'
+ self.wizard_title = None
+ self.busy_msg = ''
+ self.wizard = wizard
+ self._error = ''
+ self._valid = False
+ self._busy = False
+
+ @property
+ def valid(self):
+ return self._valid
+
+ @valid.setter
+ def valid(self, is_valid):
+ if self._valid != is_valid:
+ self._valid = is_valid
+ self.on_updated()
+
+ @property
+ def busy(self):
+ return self._busy
+
+ @busy.setter
+ def busy(self, is_busy):
+ if self._busy != is_busy:
+ self._busy = is_busy
+ self.on_updated()
+
+ @property
+ def error(self):
+ return self._error
+
+ @error.setter
+ def error(self, error):
+ if self._error != error:
+ self._error = error
+ self.on_updated()
+
+ @abstractmethod
+ def apply(self):
+ # called to apply UI component values to wizard_data
+ pass
+
+ def on_ready(self):
+ # called when wizard_data is available
+ pass
+
+ @pyqtSlot()
+ def on_updated(self, *args):
+ try:
+ self.updated.emit(self)
+ except RuntimeError:
+ pass
+
+ def initialFocus(self) -> Optional[QWidget]:
+ """Override to specify a control that should receive initial focus"""
+ return None
diff --git a/electrum/gui/stdio.py b/electrum/gui/stdio.py
new file mode 100644
index 000000000000..34eab29c3323
--- /dev/null
+++ b/electrum/gui/stdio.py
@@ -0,0 +1,261 @@
+from decimal import Decimal
+import getpass
+import datetime
+import logging
+from typing import Optional
+
+from electrum.gui import BaseElectrumGui
+from electrum import util
+from electrum import WalletStorage, Wallet
+from electrum.wallet import Abstract_Wallet
+from electrum.wallet_db import WalletDB
+from electrum.util import format_satoshis, EventListener, event_listener
+from electrum.bitcoin import is_address, COIN
+from electrum.transaction import PartialTxOutput
+from electrum.network import TxBroadcastError, BestEffortRequestFailed
+from electrum.fee_policy import FixedFeePolicy
+
+_ = lambda x:x # i18n
+
+# minimal fdisk like gui for console usage
+# written by rofl0r, with some bits stolen from the text gui (ncurses)
+
+
+class ElectrumGui(BaseElectrumGui, EventListener):
+
+ def __init__(self, *, config, daemon, plugins):
+ BaseElectrumGui.__init__(self, config=config, daemon=daemon, plugins=plugins)
+ self.network = daemon.network
+ storage = WalletStorage(config.get_wallet_path())
+ password = None
+ if not storage.file_exists():
+ print("Wallet not found. try 'electrum create'")
+ exit()
+ if storage.is_encrypted():
+ password = getpass.getpass('Password:', stream=None)
+ storage.decrypt(password)
+ del storage
+ self.wallet = self.daemon.load_wallet(config.get_wallet_path(), password)
+ self.contacts = self.wallet.contacts
+
+ self.done = 0
+ self.last_balance = ""
+
+ self.str_recipient = ""
+ self.str_description = ""
+ self.str_amount = ""
+ self.str_fee = ""
+
+ self.register_callbacks()
+ self.commands = [_("[h] - displays this help text"), \
+ _("[i] - display transaction history"), \
+ _("[o] - enter payment order"), \
+ _("[p] - print stored payment order"), \
+ _("[s] - send stored payment order"), \
+ _("[r] - show own receipt addresses"), \
+ _("[c] - display contacts"), \
+ _("[b] - print server banner"), \
+ _("[q] - quit")]
+ self.num_commands = len(self.commands)
+
+ @event_listener
+ def on_event_wallet_updated(self, wallet):
+ self.updated()
+
+ @event_listener
+ def on_event_network_updated(self):
+ self.updated()
+
+ @event_listener
+ def on_event_banner(self, *args):
+ self.print_banner()
+
+ def main_command(self):
+ self.print_balance()
+ c = input("enter command: ")
+ if c == "h" : self.print_commands()
+ elif c == "i" : self.print_history()
+ elif c == "o" : self.enter_order()
+ elif c == "p" : self.print_order()
+ elif c == "s" : self.send_order()
+ elif c == "r" : self.print_addresses()
+ elif c == "c" : self.print_contacts()
+ elif c == "b" : self.print_banner()
+ elif c == "n" : self.network_dialog()
+ elif c == "e" : self.settings_dialog()
+ elif c == "q" : self.done = 1
+ else: self.print_commands()
+
+ def updated(self):
+ s = self.get_balance()
+ if s != self.last_balance:
+ print(s)
+ self.last_balance = s
+ return True
+
+ def print_commands(self):
+ self.print_list(self.commands, "Available commands")
+
+ def print_history(self):
+ width = [20, 40, 14, 14]
+ delta = (80 - sum(width) - 4)/3
+ format_str = "%"+"%d"%width[0]+"s"+"%"+"%d"%(width[1]+delta)+"s"+"%" \
+ + "%d"%(width[2]+delta)+"s"+"%"+"%d"%(width[3]+delta)+"s"
+ messages = []
+ domain = self.wallet.get_addresses()
+ for hist_item in reversed(self.wallet.adb.get_history(domain)):
+ if hist_item.tx_mined_status.conf:
+ timestamp = hist_item.tx_mined_status.timestamp
+ try:
+ time_str = datetime.datetime.fromtimestamp(timestamp).isoformat(' ')[:-3]
+ except Exception:
+ time_str = "unknown"
+ else:
+ time_str = 'unconfirmed'
+
+ label = self.wallet.get_label_for_txid(hist_item.txid)
+ messages.append(format_str % (
+ time_str, label,
+ format_satoshis(hist_item.delta, whitespaces=True),
+ format_satoshis(hist_item.balance, whitespaces=True)))
+
+ self.print_list(messages[::-1], format_str%(_("Date"), _("Description"), _("Amount"), _("Balance")))
+
+
+ def print_balance(self):
+ print(self.get_balance())
+
+ def get_balance(self):
+ network = self.wallet.network
+ if network and network.is_connected():
+ if not self.wallet.is_up_to_date():
+ msg = _("Synchronizing...")
+ else:
+ c, u, x = self.wallet.get_balance()
+ msg = _("Balance")+": {} ".format(Decimal(c) / COIN)
+ if u:
+ msg += " [{} unconfirmed]".format(Decimal(u) / COIN)
+ if x:
+ msg += " [{} unmatured]".format(Decimal(x) / COIN)
+ else:
+ msg = _("Not connected")
+
+ return msg
+
+
+ def print_contacts(self):
+ messages = map(lambda x: "%20s %45s "%(x[0], x[1][1]), self.contacts.items())
+ self.print_list(messages, "%19s %25s "%("Key", "Value"))
+
+ def print_addresses(self):
+ messages = map(lambda addr: "%30s %30s "%(addr, self.wallet.get_label_for_address(addr)), self.wallet.get_addresses())
+ self.print_list(messages, "%19s %25s "%("Address", "Label"))
+
+ def print_order(self):
+ print("send order to " + self.str_recipient + ", amount: " + self.str_amount \
+ + "\nfee: " + self.str_fee + ", desc: " + self.str_description)
+
+ def enter_order(self):
+ self.str_recipient = input("Pay to: ")
+ self.str_description = input("Description : ")
+ self.str_amount = input("Amount: ")
+ self.str_fee = input("Fee: ")
+
+ def send_order(self):
+ self.do_send()
+
+ def print_banner(self):
+ for i, x in enumerate(self.wallet.network.banner.split('\n')):
+ print(x)
+
+ def print_list(self, lst, firstline):
+ lst = list(lst)
+ self.maxpos = len(lst)
+ if not self.maxpos: return
+ print(firstline)
+ for i in range(self.maxpos):
+ msg = lst[i] if i < len(lst) else ""
+ print(msg)
+
+
+ def main(self):
+ self.daemon.start_network()
+ while self.done == 0:
+ self.main_command()
+
+ def do_send(self):
+ if not is_address(self.str_recipient):
+ print(_('Invalid Bitcoin address'))
+ return
+ try:
+ amount = int(Decimal(self.str_amount) * COIN)
+ except Exception:
+ print(_('Invalid Amount'))
+ return
+ try:
+ fee = int(Decimal(self.str_fee) * COIN)
+ except Exception:
+ print(_('Invalid Fee'))
+ return
+
+ if self.wallet.has_password():
+ password = self.password_dialog()
+ if not password:
+ return
+ else:
+ password = None
+
+ c = ""
+ while c != "y":
+ c = input("ok to send (y/n)?")
+ if c == "n": return
+
+ try:
+ tx = self.wallet.make_unsigned_transaction(
+ outputs=[PartialTxOutput.from_address_and_value(self.str_recipient, amount)],
+ fee_policy=FixedFeePolicy(fee),
+ )
+ self.wallet.sign_transaction(tx, password)
+ except Exception as e:
+ print(repr(e))
+ return
+
+ if self.str_description:
+ self.wallet.set_label(tx.txid(), self.str_description)
+
+ print(_("Please wait..."))
+ try:
+ self.network.run_from_another_thread(self.network.broadcast_transaction(tx))
+ except TxBroadcastError as e:
+ msg = e.get_message_for_gui()
+ print(msg)
+ except BestEffortRequestFailed as e:
+ msg = repr(e)
+ print(msg)
+ else:
+ print(_('Payment sent.'))
+ #self.do_clear()
+ #self.update_contacts_tab()
+
+ def network_dialog(self):
+ print("use 'electrum setconfig server/proxy' to change your network settings")
+ return True
+
+
+ def settings_dialog(self):
+ print("use 'electrum setconfig' to change your settings")
+ return True
+
+ def password_dialog(self):
+ return getpass.getpass()
+
+
+# XXX unused
+
+ def run_receive_tab(self, c):
+ #if c == 10:
+ # out = self.run_popup('Address', ["Edit label", "Freeze", "Prioritize"])
+ return
+
+ def run_contacts_tab(self, c):
+ pass
diff --git a/electrum/gui/text.py b/electrum/gui/text.py
new file mode 100644
index 000000000000..1c86193100f5
--- /dev/null
+++ b/electrum/gui/text.py
@@ -0,0 +1,945 @@
+import tty
+import sys
+import curses
+import datetime
+import locale
+from decimal import Decimal
+import getpass
+from typing import TYPE_CHECKING, Optional
+
+# 3rd-party dependency:
+try:
+ import pyperclip
+except ImportError: # only use vendored lib as fallback, to allow Linux distros to bring their own
+ from electrum._vendor import pyperclip
+
+from electrum.gui import BaseElectrumGui
+from electrum.bip21 import parse_bip21_URI
+from electrum.util import format_time
+from electrum.util import EventListener, event_listener
+from electrum.bitcoin import is_address, address_to_script
+from electrum.transaction import PartialTxOutput
+from electrum.wallet import Wallet, Abstract_Wallet
+from electrum.wallet_db import WalletDB
+from electrum.storage import WalletStorage
+from electrum.network import NetworkParameters, TxBroadcastError, BestEffortRequestFailed, ProxySettings
+from electrum.interface import ServerAddr
+from electrum.invoices import Invoice
+from electrum.fee_policy import FeePolicy
+
+if TYPE_CHECKING:
+ from electrum.daemon import Daemon
+ from electrum.simple_config import SimpleConfig
+ from electrum.plugin import Plugins
+
+
+_ = lambda x:x # i18n
+
+
+# ascii key codes
+KEY_BACKSPACE = 8
+KEY_ESC = 27
+KEY_DELETE = 127
+
+
+def parse_bip21(text):
+ try:
+ return parse_bip21_URI(text)
+ except Exception:
+ return
+
+
+def parse_bolt11(text):
+ from electrum.bolt11 import decode_bolt11_invoice
+ try:
+ return decode_bolt11_invoice(text)
+ except Exception:
+ return
+
+
+class ElectrumGui(BaseElectrumGui, EventListener):
+
+ def __init__(self, *, config: 'SimpleConfig', daemon: 'Daemon', plugins: 'Plugins'):
+ BaseElectrumGui.__init__(self, config=config, daemon=daemon, plugins=plugins)
+ self.network = daemon.network
+ storage = WalletStorage(config.get_wallet_path())
+ password = None
+ if not storage.file_exists():
+ print("Wallet not found. try 'electrum create'")
+ exit()
+ if storage.is_encrypted():
+ password = getpass.getpass('Password:', stream=None)
+ del storage
+ self.wallet = self.daemon.load_wallet(config.get_wallet_path(), password)
+ self.contacts = self.wallet.contacts
+
+ locale.setlocale(locale.LC_ALL, '')
+ self.encoding = locale.getpreferredencoding()
+
+ self.stdscr = curses.initscr()
+ curses.noecho()
+ curses.cbreak()
+ curses.start_color()
+ curses.use_default_colors()
+ curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE)
+ curses.init_pair(2, curses.COLOR_WHITE, curses.COLOR_CYAN)
+ curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_WHITE)
+ curses.halfdelay(1)
+ self.stdscr.keypad(True)
+ self.stdscr.border(0)
+ self.maxy, self.maxx = self.stdscr.getmaxyx()
+ self.set_cursor(0)
+ self.w = curses.newwin(10, 50, 5, 5)
+
+ self.lightning_invoice = None
+ self.tab = 0
+ self.pos = 0
+ self.popup_pos = 0
+
+ self.str_recipient = ""
+ self.str_description = ""
+ self.str_amount = ""
+ self.history = None
+ self.txid = []
+ self.str_recv_description = ""
+ self.str_recv_amount = ""
+ self.str_recv_expiry = ""
+ self.channel_ids = []
+ self.requests = []
+
+ self.register_callbacks()
+ self.tab_names = [_("History"), _("Send"), _("Receive"), _("Addresses"), _("Coins"), _("Channels"), _("Contacts"), _("Banner")]
+ self.num_tabs = len(self.tab_names)
+ self.need_update = False
+
+ def stop(self):
+ self.tab = -1
+
+ @event_listener
+ def on_event_wallet_updated(self, wallet):
+ self.need_update = True
+
+ @event_listener
+ def on_event_network_updated(self):
+ self.need_update = True
+
+ def set_cursor(self, x):
+ try:
+ curses.curs_set(x)
+ except Exception:
+ pass
+
+ def restore_or_create(self):
+ pass
+
+ def verify_seed(self):
+ pass
+
+ def get_string(self, y, x) -> str:
+ self.set_cursor(1)
+ curses.echo()
+ self.stdscr.addstr(y, x, " "*20, curses.A_REVERSE)
+ s = self.stdscr.getstr(y,x).decode()
+ curses.noecho()
+ self.set_cursor(0)
+ return s
+
+ def update(self):
+ self.update_history()
+ if self.tab == 0:
+ self.print_history()
+ self.refresh()
+ self.need_update = False
+
+ def print_button(self, x, y, text, pos):
+ self.stdscr.addstr(x, y, text, curses.A_REVERSE if self.pos%self.max_pos==pos else curses.color_pair(2))
+
+ def print_edit_line(self, y, x, label, text, index, size):
+ text += " "*(size - len(text))
+ self.stdscr.addstr(y, x, label)
+ self.stdscr.addstr(y, x + 13, text, curses.A_REVERSE if self.pos%self.max_pos==index else curses.color_pair(1))
+
+ def print_history(self):
+ x = 2
+ self.history_format_str = self.format_column_width(x, [-20, '*', 15, 15])
+ if self.history is None:
+ self.update_history()
+ self.print_list(2, x, self.history[::-1], headers=self.history_format_str%(_("Date"), _("Description"), _("Amount"), _("Balance")))
+
+ def update_history(self):
+ width = [20, 40, 14, 14]
+ delta = (self.maxx - sum(width) - 4)/3
+ domain = self.wallet.get_addresses()
+ self.history = []
+ self.txid = []
+ balance_sat = 0
+ for item in self.wallet.get_full_history().values():
+ amount_sat = item['value'].value
+ balance_sat += amount_sat
+ if item.get('lightning'):
+ timestamp = item['timestamp']
+ label = self.wallet.get_label_for_rhash(item['payment_hash'])
+ self.txid.insert(0, item['payment_hash'])
+ else:
+ conf = item['confirmations']
+ timestamp = item['timestamp'] if conf > 0 else 0
+ label = self.wallet.get_label_for_txid(item['txid'])
+ self.txid.insert(0, item['txid'])
+ if timestamp:
+ time_str = datetime.datetime.fromtimestamp(timestamp).isoformat(' ')[:-3]
+ else:
+ time_str = 'unconfirmed'
+
+ if len(label) > 40:
+ label = label[0:37] + '...'
+ self.history.append(self.history_format_str % (
+ time_str, label,
+ self.config.format_amount(amount_sat, whitespaces=True),
+ self.config.format_amount(balance_sat, whitespaces=True)))
+
+ def print_clipboard(self):
+ return
+ c = pyperclip.paste()
+ if c:
+ if len(c) > 20:
+ c = c[0:20] + '...'
+ self.stdscr.addstr(self.maxy -1, self.maxx // 3, ' ' + _('Clipboard') + ': ' + c + ' ')
+
+ def print_balance(self):
+ if not self.network:
+ msg = _("Offline")
+ elif self.network.is_connected():
+ if not self.wallet.is_up_to_date():
+ msg = _("Synchronizing...")
+ else:
+ balance = self.wallet.get_balances_for_piechart().total()
+ msg = _("Balance") + ': ' + self.config.format_amount_and_units(balance)
+ else:
+ msg = _("Not connected")
+ msg = ' ' + msg + ' '
+ self.stdscr.addstr(self.maxy -1, 3, msg)
+ for i in range(self.num_tabs):
+ self.stdscr.addstr(0, 2 + 2*i + len(''.join(self.tab_names[0:i])), ' '+self.tab_names[i]+' ', curses.A_REVERSE if self.tab == i else 0)
+ self.stdscr.addstr(self.maxy -1, self.maxx-30, ' ' + ' '.join([_("Settings"), _("Network"), _("Quit")]) + ' ')
+
+ def print_receive_tab(self):
+ self.stdscr.clear()
+ self.buttons = {}
+ self.max_pos = 6 + len(list(self.wallet.get_unpaid_requests()))
+ self.index = 0
+ self.add_edit_line(3, 2, _("Description"), self.str_recv_description, 40)
+ self.add_edit_line(5, 2, _("Amount"), self.str_recv_amount, 15)
+ self.stdscr.addstr(5, 31, self.config.get_base_unit())
+ self.add_edit_line(7, 2, _("Expiry"), self.str_recv_expiry, 15)
+ self.add_button(9, 15, _("[Clear]"), self.do_clear_request)
+ self.add_button(9, 25, _("[Onchain]"), lambda: self.do_create_request(lightning=False))
+ self.add_button(9, 35, _("[Lightning]"), lambda: self.do_create_request(lightning=True))
+ self.print_requests_list(13, 2, offset_pos=6)
+ return
+
+ def run_receive_tab(self, c):
+ if self.pos == 0:
+ self.str_recv_description = self.edit_str(self.str_recv_description, c)
+ elif self.pos == 1:
+ self.str_recv_amount = self.edit_str(self.str_recv_amount, c)
+ elif self.pos in self.buttons and c == ord("\n"):
+ self.buttons[self.pos]()
+ elif self.pos >= 6 and c == ord("\n"):
+ key = self.requests[self.pos - 6]
+ self.show_request(key)
+
+ def question(self, msg):
+ out = self.run_popup(msg, ["No", "Yes"]).get('button')
+ return out == "Yes"
+
+ def show_invoice_menu(self):
+ key = self.invoices[self.pos - 7]
+ invoice = self.wallet.get_invoice(key)
+ out = self.run_popup('Invoice', ["Pay", "Delete"]).get('button')
+ if out == "Pay":
+ self.do_pay_invoice(invoice)
+ elif out == "Delete":
+ self.wallet.delete_invoice(key)
+ self.max_pos -= 1
+
+ def format_column_width(self, offset, width):
+ delta = self.maxx -2 -offset - sum([abs(x) for x in width if x != '*'])
+ fmt = ''
+ for w in width:
+ if w == '*':
+ fmt += "%-" + "%d"%delta + "s"
+ else:
+ fmt += "%" + "%d"%w + "s"
+ return fmt
+
+ def print_invoices_list(self, y, x, offset_pos):
+ messages = []
+ invoices = []
+ fmt = self.format_column_width(x, [-20, '*', 15, 25])
+ headers = fmt % ("Date", "Description", "Amount", "Status")
+ for req in self.wallet.get_unpaid_invoices():
+ key = req.get_id()
+ status = self.wallet.get_invoice_status(req)
+ status_str = req.get_status_str(status)
+ timestamp = req.get_time()
+ date = format_time(timestamp)
+ amount = req.get_amount_sat()
+ message = req.get_message()
+ amount_str = self.config.format_amount(amount) if amount else ""
+ labels = []
+ messages.append(fmt % (date, message, amount_str, status_str))
+ invoices.append(key)
+ self.invoices = invoices
+ self.print_list(y, x, messages, headers=headers, offset_pos=offset_pos)
+
+ def print_requests_list(self, y, x, offset_pos):
+ messages = []
+ requests = []
+ fmt = self.format_column_width(x, [-20, '*', 15, 25])
+ headers = fmt % ("Date", "Description", "Amount", "Status")
+ for req in self.wallet.get_unpaid_requests():
+ key = req.get_id()
+ status = self.wallet.get_invoice_status(req)
+ status_str = req.get_status_str(status)
+ timestamp = req.get_time()
+ date = format_time(timestamp)
+ amount = req.get_amount_sat()
+ message = req.get_message()
+ amount_str = self.config.format_amount(amount) if amount else ""
+ labels = []
+ messages.append(fmt % (date, message, amount_str, status_str))
+ requests.append(key)
+ self.requests = requests
+ self.print_list(y, x, messages, headers=headers, offset_pos=offset_pos)
+
+ def print_contacts(self):
+ messages = list(map(lambda x: "%20s %45s "%(x[0], x[1][1]), self.contacts.items()))
+ self.print_list(2, 1, messages, "%19s %15s "%("Key", "Value"))
+
+ def print_addresses(self):
+ x = 2
+ fmt = self.format_column_width(x, [-50, '*', 15])
+ messages = [ fmt % (
+ addr,
+ self.wallet.get_label_for_address(addr),
+ self.config.format_amount(sum(self.wallet.get_addr_balance(addr)), whitespaces=True)
+ ) for addr in self.wallet.get_addresses() ]
+ self.print_list(2, x, messages, fmt % ("Address", "Description", "Balance"))
+
+ def print_utxos(self):
+ x = 2
+ fmt = self.format_column_width(x, [-70, '*', 15])
+ utxos = self.wallet.get_utxos()
+ messages = [ fmt % (
+ utxo.prevout.to_str(),
+ self.wallet.get_label_for_txid(utxo.prevout.txid.hex()),
+ self.config.format_amount(utxo.value_sats(), whitespaces=True)
+ ) for utxo in utxos]
+ self.print_list(2, x, sorted(messages), fmt % ("Outpoint", "Description", "Balance"))
+
+ def print_channels(self):
+ if not self.wallet.lnworker:
+ return
+ fmt = "%-35s %-10s %-30s"
+ channels = self.wallet.lnworker.get_channel_objects()
+ messages = []
+ channel_ids = []
+ for chan in channels.values():
+ channel_ids.append(chan.short_id_for_GUI())
+ messages.append(fmt % (chan.short_id_for_GUI(), self.config.format_amount(chan.get_capacity()), chan.get_state().name))
+ self.channel_ids = channel_ids
+ self.print_list(2, 1, messages, fmt % ("Scid", "Capacity", "State"))
+
+ def print_send_tab(self):
+ self.stdscr.clear()
+ self.buttons = {}
+ self.max_pos = 7 + len(list(self.wallet.get_unpaid_invoices()))
+ self.index = 0
+ self.add_edit_line(3, 2, _("Pay to"), self.str_recipient, 40)
+ self.add_edit_line(5, 2, _("Description"), self.str_description, 40)
+ self.add_edit_line(7, 2, _("Amount"), self.str_amount, 15)
+ self.stdscr.addstr(7, 31, self.config.get_base_unit())
+ self.add_button(9, 15, _("[Paste]"), self.do_paste)
+ self.add_button(9, 25, _("[Clear]"), self.do_clear)
+ self.add_button(9, 35, _("[Save]"), self.do_save_invoice)
+ self.add_button(9, 44, _("[Pay]"), self.do_pay)
+ #
+ self.print_invoices_list(13, 2, offset_pos=7)
+
+ def add_edit_line(self, y, x, title, data, length):
+ self.print_edit_line(y, x, title, data, self.index, length)
+ self.index += 1
+
+ def add_button(self, y, x, title, action):
+ self.print_button(y, x, title, self.index)
+ self.buttons[self.index] = action
+ self.index += 1
+
+ def print_banner(self):
+ if self.network and self.network.banner:
+ banner = self.network.banner
+ banner = banner.replace('\r', '')
+ self.print_list(2, 1, banner.split('\n'))
+
+ def get_qr(self, data):
+ import qrcode
+ try:
+ from StringIO import StringIO
+ except ImportError:
+ from io import StringIO
+ s = StringIO()
+ self.qr = qrcode.QRCode()
+ self.qr.add_data(data)
+ self.qr.print_ascii(out=s, invert=False)
+ msg = s.getvalue()
+ lines = msg.split('\n')
+ return lines
+
+ def print_qr(self, w, y, x, lines):
+ try:
+ for i, l in enumerate(lines):
+ l = l.encode("utf-8")
+ w.addstr(y + i, x, l, curses.color_pair(3))
+ except curses.error:
+ m = 'error. screen too small?'
+ m = m.encode(self.encoding)
+ w.addstr(y, x, m, 0)
+
+ def print_list(self, y, x, lst, headers=None, offset_pos=0):
+ self.list_length = len(lst)
+ if not self.list_length:
+ return
+ if headers:
+ headers += " "*(self.maxx -2 - len(headers))
+ self.stdscr.addstr(y, x, headers, curses.A_BOLD)
+ for i in range(self.maxy - 2 - y):
+ msg = lst[i] if i < self.list_length else ""
+ msg += " "*(self.maxx - 2 - len(msg))
+ m = msg[0:self.maxx - 2]
+ m = m.encode(self.encoding)
+ selected = self.pos >= offset_pos and (i == ((self.pos - offset_pos) % self.list_length))
+ self.stdscr.addstr(i+y+1, x, m, curses.A_REVERSE if selected else 0)
+
+ self.max_pos = self.list_length + offset_pos
+
+ def refresh(self):
+ if self.tab == -1:
+ return
+ self.stdscr.border(0)
+ self.print_balance()
+ self.print_clipboard()
+ self.stdscr.refresh()
+
+ def increase_cursor(self, delta):
+ self.pos += delta
+ self.pos = max(0, self.pos)
+ self.pos = min(self.pos, self.max_pos - 1)
+
+ def getch(self, redraw=False):
+ while True:
+ c = self.stdscr.getch()
+ if c != -1:
+ return c
+ if self.need_update and redraw:
+ self.update()
+ if self.tab == -1:
+ return KEY_ESC
+
+ def main_command(self):
+ c = self.getch(redraw=True)
+ cc = curses.unctrl(c).decode()
+ if c == curses.KEY_RIGHT:
+ self.tab = (self.tab + 1)%self.num_tabs
+ elif c == curses.KEY_LEFT:
+ self.tab = (self.tab - 1)%self.num_tabs
+ elif c in [curses.KEY_DOWN, ord("\t")]:
+ self.increase_cursor(1)
+ elif c == curses.KEY_UP:
+ self.increase_cursor(-1)
+ elif cc in ['^W', '^C', '^X', '^Q']:
+ self.tab = -1
+ elif cc in ['^N']:
+ self.network_dialog()
+ elif cc == '^S':
+ self.settings_dialog()
+ else:
+ return c
+
+ def run_tab(self, i, print_func, exec_func):
+ while self.tab == i:
+ self.stdscr.clear()
+ print_func()
+ self.refresh()
+ c = self.main_command()
+ if c: exec_func(c)
+
+ def run_history_tab(self, c):
+ # Get txid from cursor position
+ if c == ord("\n"):
+ out = self.run_popup('', ['Transaction ID:', self.txid[self.pos]])
+
+ def edit_str(self, target, c, is_num=False):
+ if target is None:
+ target = ''
+ # detect backspace
+ cc = curses.unctrl(c).decode()
+ if c in [KEY_BACKSPACE, KEY_DELETE, curses.KEY_BACKSPACE] and target:
+ target = target[:-1]
+ elif not is_num or cc in '0123456789.':
+ target += cc
+ return target
+
+ def run_send_tab(self, c):
+ self.pos = self.pos % self.max_pos
+ if self.pos == 0:
+ self.str_recipient = self.edit_str(self.str_recipient, c)
+ elif self.pos == 1:
+ self.str_description = self.edit_str(self.str_description, c)
+ elif self.pos == 2:
+ self.str_amount = self.edit_str(self.str_amount, c, True)
+ elif self.pos in self.buttons and c == ord("\n"):
+ self.buttons[self.pos]()
+ elif self.pos >= 7 and c == ord("\n"):
+ self.show_invoice_menu()
+
+ def run_contacts_tab(self, c):
+ if c == ord("\n") and self.contacts:
+ out = self.run_popup('Address', ["Copy", "Pay to", "Edit label", "Delete"]).get('button')
+ key = list(self.contacts.keys())[self.pos%len(self.contacts.keys())]
+ if out == "Pay to":
+ self.tab = 1
+ self.str_recipient = key
+ self.pos = 2
+ elif out == "Edit label":
+ s = self.get_string(6 + self.pos, 18)
+ if s:
+ self.contacts[key] = ('address', s)
+ elif out == "Delete":
+ self.contacts.pop(key)
+ self.pos = 0
+
+ def run_addresses_tab(self, c):
+ pass
+
+ def run_utxos_tab(self, c):
+ pass
+
+ def run_channels_tab(self, c):
+ if c == ord("\n") and self.channel_ids:
+ out = self.run_popup('Channel Details', ['Short channel ID:', self.channel_ids[self.pos]])
+
+ def run_banner_tab(self, c):
+ self.show_message(repr(c))
+ pass
+
+ def main(self):
+ self.daemon.start_network()
+ tty.setraw(sys.stdin)
+ try:
+ while self.tab != -1:
+ self.run_tab(0, self.print_history, self.run_history_tab)
+ self.run_tab(1, self.print_send_tab, self.run_send_tab)
+ self.run_tab(2, self.print_receive_tab, self.run_receive_tab)
+ self.run_tab(3, self.print_addresses, self.run_addresses_tab)
+ self.run_tab(4, self.print_utxos, self.run_utxos_tab)
+ self.run_tab(5, self.print_channels, self.run_channels_tab)
+ self.run_tab(6, self.print_contacts, self.run_contacts_tab)
+ self.run_tab(7, self.print_banner, self.run_banner_tab)
+ except curses.error as e:
+ raise Exception("Error with curses. Is your screen too small?") from e
+ finally:
+ tty.setcbreak(sys.stdin)
+ curses.nocbreak()
+ self.stdscr.keypad(False)
+ curses.echo()
+ curses.endwin()
+
+ def do_clear(self):
+ self.str_amount = ''
+ self.str_recipient = ''
+ self.str_fee = ''
+ self.str_description = ''
+
+ def do_create_request(self, lightning: bool):
+ amount_sat = self.parse_amount(self.str_recv_amount) or 0
+ if not lightning:
+ if amount_sat and amount_sat < self.wallet.dust_threshold():
+ self.show_message(_('Amount too low'))
+ return
+ address = self.wallet.get_unused_address()
+ if not address:
+ self.show_message(_('No more unused address'))
+ return
+ else:
+ if not self.wallet.has_lightning():
+ self.show_message(_('Lightning is disabled on this wallet'))
+ return
+ address = None
+
+ message = self.str_recv_description
+ expiry = self.config.WALLET_PAYREQ_EXPIRY_SECONDS
+ key = self.wallet.create_request(amount_sat, message, expiry, address)
+ self.do_clear_request()
+ self.pos = self.max_pos
+ self.show_request(key)
+
+ def do_clear_request(self):
+ self.str_recv_amount = ""
+ self.str_recv_description = ""
+
+ def do_paste(self):
+ text = pyperclip.paste()
+ text = text.strip()
+ if not text:
+ return
+ if is_address(text):
+ self.str_recipient = text
+ self.lightning_invoice = None
+ elif out := parse_bip21(text):
+ amount_sat = out.get('amount')
+ self.str_amount = self.config.format_amount(amount_sat) if amount_sat is not None else ''
+ self.str_recipient = out.get('address') or ''
+ self.str_description = out.get('message') or ''
+ self.lightning_invoice = None
+ elif lnaddr := parse_bolt11(text):
+ amount_sat = lnaddr.get_amount_sat()
+ self.str_recipient = lnaddr.pubkey.serialize().hex()
+ self.str_description = lnaddr.get_description()
+ self.str_amount = self.config.format_amount(amount_sat) if amount_sat is not None else ''
+ self.lightning_invoice = text
+ else:
+ self.show_message(_('Could not parse clipboard text') + '\n\n' + text[0:20] + '...')
+
+ def parse_amount(self, text):
+ try:
+ x = Decimal(text)
+ except Exception:
+ return None
+ power = pow(10, self.config.BTC_AMOUNTS_DECIMAL_POINT)
+ return int(power * x)
+
+ def read_invoice(self):
+ if self.lightning_invoice:
+ invoice = Invoice.from_bech32(self.lightning_invoice)
+ if invoice.amount_msat is None:
+ amount_sat = self.parse_amount(self.str_amount)
+ if amount_sat:
+ invoice.set_amount_msat(int(amount_sat * 1000))
+ else:
+ self.show_message(_('No amount'))
+ return None
+ elif is_address(self.str_recipient):
+ amount_sat = self.parse_amount(self.str_amount)
+ if not amount_sat:
+ self.show_message(_('No amount'))
+ return None
+ scriptpubkey = address_to_script(self.str_recipient)
+ outputs = [PartialTxOutput(scriptpubkey=scriptpubkey, value=amount_sat)]
+ invoice = self.wallet.create_invoice(
+ outputs=outputs,
+ message=self.str_description,
+ URI=None,
+ )
+ else:
+ self.show_message(_('Invalid Bitcoin address'))
+ return None
+ return invoice
+
+ def do_save_invoice(self):
+ invoice = self.read_invoice()
+ if not invoice:
+ return
+ self.save_pending_invoice(invoice)
+
+ def save_pending_invoice(self, invoice):
+ self.do_clear()
+ self.wallet.save_invoice(invoice)
+ self.pending_invoice = None
+
+ def do_pay(self):
+ invoice = self.read_invoice()
+ if not invoice:
+ return
+ self.do_pay_invoice(invoice)
+
+ def do_pay_invoice(self, invoice):
+ if invoice.is_lightning():
+ self.pay_lightning_invoice(invoice)
+ else:
+ self.pay_onchain_dialog(invoice)
+
+ def pay_lightning_invoice(self, invoice):
+ amount_msat = invoice.get_amount_msat()
+ msg = _("Pay lightning invoice?")
+ #+ '\n\n' + _("This will send {}?").format(self.format_amount_and_units(Decimal(amount_msat)/1000))
+ if not self.question(msg):
+ return
+ self.save_pending_invoice(invoice)
+ coro = self.wallet.lnworker.pay_invoice(invoice, amount_msat=amount_msat)
+
+ #self.window.run_coroutine_from_thread(coro, _('Sending payment'))
+ self.show_message(_("Please wait..."), getchar=False)
+ try:
+ self.network.run_from_another_thread(coro)
+ except Exception as e:
+ self.show_message(str(e))
+ else:
+ self.show_message(_('Payment sent.'))
+
+ def pay_onchain_dialog(self, invoice):
+ if self.wallet.has_password():
+ password = self.password_dialog()
+ if not password:
+ return
+ else:
+ password = None
+ fee_policy = FeePolicy(self.config.FEE_POLICY)
+ try:
+ tx = self.wallet.make_unsigned_transaction(
+ outputs=invoice.outputs,
+ fee_policy=fee_policy,
+ )
+ self.wallet.sign_transaction(tx, password)
+ except Exception as e:
+ self.show_message(repr(e))
+ return
+ if self.str_description:
+ self.wallet.set_label(tx.txid(), self.str_description)
+
+ self.save_pending_invoice(invoice)
+ self.show_message(_("Please wait..."), getchar=False)
+ try:
+ self.network.run_from_another_thread(self.network.broadcast_transaction(tx))
+ except TxBroadcastError as e:
+ msg = e.get_message_for_gui()
+ self.show_message(msg)
+ except BestEffortRequestFailed as e:
+ msg = repr(e)
+ self.show_message(msg)
+ else:
+ self.show_message(_('Payment sent.'))
+ self.do_clear()
+ #self.update_contacts_tab()
+
+ def show_message(self, message, getchar = True):
+ w = self.w
+ w.clear()
+ w.border(0)
+ for i, line in enumerate(message.split('\n')):
+ w.addstr(2+i,2,line)
+ w.refresh()
+ if getchar:
+ c = self.getch()
+
+ def run_popup(self, title, items):
+ return self.run_dialog(title, list(map(lambda x: {'type':'button','label':x}, items)), interval=1, y_pos = self.pos+3)
+
+ def network_dialog(self):
+ if not self.network:
+ return
+ net_params = self.network.get_parameters()
+ server_addr = net_params.server
+ proxy_config, auto_connect = net_params.proxy, net_params.auto_connect
+ srv = 'auto-connect' if auto_connect else str(self.network.default_server)
+ out = self.run_dialog('Network', [
+ {'label': 'server', 'type': 'str', 'value': srv},
+ {'label': 'proxy', 'type': 'str', 'value': self.config.NETWORK_PROXY},
+ {'label': 'proxy user', 'type': 'str', 'value': self.config.NETWORK_PROXY_USER},
+ {'label': 'proxy pass', 'type': 'str', 'value': self.config.NETWORK_PROXY_PASSWORD},
+ ], buttons=1)
+ if out:
+ self.show_message(repr(proxy_config))
+ if out.get('server'):
+ server_str = out.get('server')
+ auto_connect = server_str == 'auto-connect'
+ if not auto_connect:
+ try:
+ server_addr = ServerAddr.from_str(server_str)
+ except Exception:
+ self.show_message("Error:" + server_str + "\nIn doubt, type \"auto-connect\"")
+ return False
+ if out.get('server') or out.get('proxy') or out.get('proxy user') or out.get('proxy pass'):
+ if out.get('proxy'):
+ new_proxy_config = ProxySettings()
+ new_proxy_config.deserialize_proxy_cfgstr(out.get('proxy'))
+ new_proxy_config.user = out.get('proxy user', proxy_config.user)
+ new_proxy_config.password = out.get('proxy pass', proxy_config.password)
+ new_proxy_config.enabled = True
+ else:
+ new_proxy_config = proxy_config
+ net_params = NetworkParameters(
+ server=server_addr,
+ proxy=new_proxy_config,
+ auto_connect=auto_connect)
+ self.network.run_from_another_thread(self.network.set_parameters(net_params))
+
+ def settings_dialog(self):
+ from electrum.fee_policy import FeePolicy
+ out = self.run_dialog('Settings', [
+ {'label':'Fee policy', 'type':'str', 'value': self.config.FEE_POLICY}
+ ], buttons = 1)
+ if out:
+ if descr := out.get('Fee policy'):
+ fee_policy = FeePolicy(descr)
+ self.config.FEE_POLICY = fee_policy.get_descriptor()
+
+ def password_dialog(self):
+ out = self.run_dialog('Password', [
+ {'label':'Password', 'type':'password', 'value':''}
+ ], buttons = 1)
+ return out.get('Password')
+
+ def run_dialog(self, title, items, interval=2, buttons=None, y_pos=3):
+ self.popup_pos = 0
+
+ self.w = curses.newwin(5 + len(list(items))*interval + (2 if buttons else 0), 68, y_pos, 5)
+ w = self.w
+ out = {}
+ while True:
+ w.clear()
+ w.border(0)
+ w.addstr(0, 2, title)
+ num = len(list(items))
+ numpos = num
+ if buttons:
+ numpos += 2
+ for i in range(num):
+ item = items[i]
+ label = item.get('label')
+ if item.get('type') == 'list':
+ value = item.get('value','')
+ elif item.get('type') == 'satoshis':
+ value = item.get('value','')
+ elif item.get('type') == 'str':
+ value = item.get('value','')
+ elif item.get('type') == 'password':
+ value = '*'*len(item.get('value',''))
+ else:
+ value = ''
+ if value is None:
+ value = ''
+ if len(value)<20:
+ value += ' '*(20-len(value))
+
+ if 'value' in item:
+ w.addstr(2+interval*i, 2, label)
+ w.addstr(2+interval*i, 15, value, curses.A_REVERSE if self.popup_pos%numpos==i else curses.color_pair(1))
+ else:
+ w.addstr(2+interval*i, 2, label, curses.A_REVERSE if self.popup_pos%numpos==i else 0)
+
+ if buttons:
+ w.addstr(5+interval*i, 10, "[ ok ]", curses.A_REVERSE if self.popup_pos%numpos==(numpos-2) else curses.color_pair(2))
+ w.addstr(5+interval*i, 25, "[cancel]", curses.A_REVERSE if self.popup_pos%numpos==(numpos-1) else curses.color_pair(2))
+
+ w.refresh()
+
+ c = self.getch()
+ if c in [ord('q'), KEY_ESC]:
+ break
+ elif c in [curses.KEY_LEFT, curses.KEY_UP]:
+ self.popup_pos -= 1
+ elif c in [curses.KEY_RIGHT, curses.KEY_DOWN]:
+ self.popup_pos +=1
+ else:
+ i = self.popup_pos%numpos
+ if buttons and c == ord("\n"):
+ if i == numpos-2:
+ return out
+ elif i == numpos -1:
+ return {}
+
+ item = items[i]
+ _type = item.get('type')
+
+ if _type == 'str':
+ item['value'] = self.edit_str(item['value'], c)
+ out[item.get('label')] = item.get('value')
+
+ elif _type == 'password':
+ item['value'] = self.edit_str(item['value'], c)
+ out[item.get('label')] = item ['value']
+
+ elif _type == 'satoshis':
+ item['value'] = self.edit_str(item['value'], c, True)
+ out[item.get('label')] = item.get('value')
+
+ elif _type == 'list':
+ choices = item.get('choices')
+ try:
+ j = choices.index(item.get('value'))
+ except Exception:
+ j = 0
+ new_choice = choices[(j + 1)% len(choices)]
+ item['value'] = new_choice
+ out[item.get('label')] = item.get('value')
+
+ elif _type == 'button':
+ out['button'] = item.get('label')
+ break
+ return out
+
+ def print_textbox(self, w, y, x, _text, highlighted):
+ width = 60
+ for i in range(len(_text)//width + 1):
+ s = _text[i*width:(i+1)*width]
+ w.addstr(y+i, x, s, curses.A_REVERSE if highlighted else curses.A_NORMAL)
+ return i
+
+ def show_request(self, key):
+ req = self.wallet.get_request(key)
+ addr = req.get_address() or ''
+ URI = self.wallet.get_request_URI(req) or ''
+ lnaddr = self.wallet.get_bolt11_invoice(req) or ''
+ w = curses.newwin(self.maxy - 2, self.maxx - 2, 1, 1)
+ pos = 2
+ text = URI or addr or lnaddr
+ data = URI or addr or lnaddr.upper()
+ while True:
+ w.clear()
+ w.border(0)
+ w.addstr(0, 2, ' ' + _('Payment Request') + ' ')
+ y = 2
+ if URI:
+ w.addstr(y, 2, "URI")
+ h = self.print_textbox(w, y, 13, URI, False)
+ elif addr:
+ w.addstr(y, 2, "Address")
+ h = self.print_textbox(w, y, 13, addr, False)
+ elif lnaddr:
+ w.addstr(y, 2, "Lightning")
+ h = self.print_textbox(w, y, 13, lnaddr, False)
+ else:
+ return
+ y += h + 2
+ lines = self.get_qr(data)
+ qr_width = len(lines) * 2
+ x = self.maxx - qr_width
+ if x > 60:
+ self.print_qr(w, 1, x, lines)
+ else:
+ w.addstr(y, 35, "(Window too small for QR code)")
+ w.addstr(y, 13, "[Copy]", curses.A_REVERSE if pos==0 else curses.color_pair(2))
+ w.addstr(y, 23, "[Delete]", curses.A_REVERSE if pos==1 else curses.color_pair(2))
+ w.addstr(y, 35, "[Close]", curses.A_REVERSE if pos==2 else curses.color_pair(2))
+ w.refresh()
+ c = self.getch()
+ if c in [curses.KEY_UP, curses.KEY_LEFT]:
+ pos -= 1
+ elif c in [curses.KEY_DOWN, curses.KEY_RIGHT, ord("\t")]:
+ pos += 1
+ elif c == ord("\n"):
+ if pos == 0:
+ pyperclip.copy(text)
+ self.show_message('Text copied to clipboard')
+ elif pos == 1:
+ if self.question("Delete Request?"):
+ self.wallet.delete_request(key)
+ self.max_pos -= 1
+ break
+ elif pos == 2:
+ break
+ else:
+ break
+ pos = pos % 3
+ self.stdscr.refresh()
+ return
diff --git a/electrum/harden_memory_linux.py b/electrum/harden_memory_linux.py
new file mode 100644
index 000000000000..9d1c8bb82549
--- /dev/null
+++ b/electrum/harden_memory_linux.py
@@ -0,0 +1,94 @@
+# Copyright (C) 2020 cptpcrd
+# Copyright (C) 2025 The Electrum developers
+# Distributed under the MIT software license, see the accompanying
+# file LICENCE or http://www.opensource.org/licenses/mit-license.php
+#
+# based on https://github.com/cptpcrd/pyprctl/blob/578ed3e81066a8a61dede912454d5eeaef37eeea/pyprctl/ffi.py#L28
+#
+# This module tries to restrict the ability of other processes to access the memory of our process.
+# Traditionally, on Linux, one process can access the memory of another arbitrary process
+# if both are running as the same user (uid). (Root can ofc access the memory of ~any process)
+# Programs can opt-out from this by setting prctl(PR_SET_DUMPABLE, 0);
+#
+# Besides PR_SET_DUMPABLE, there are ways to globally restrict this for all processes:
+# 1. The Yama (Linux Security Module) ptrace scope can be used to reduce these permissions
+# This runtime kernel parameter can be set to the following options:
+# 0 - Default attach security permissions.
+# 1 - Restricted attach. Only child processes plus normal permissions.
+# 2 - Admin-only attach. Only executables with CAP_SYS_PTRACE.
+# 3 - No attach. No process may call ptrace at all. Irrevocable.
+# # Note: The default value of kernel.yama.ptrace_scope is distro-specific.
+# # See `$ cat /proc/sys/kernel/yama/ptrace_scope`.
+# # - ubuntu 22.04 sets it to 1 (see /etc/sysctl.d/10-ptrace.conf),
+# # - debian 12 sets it to 0
+# # - manjaro sets it to 1
+# 2. SELinux: ptrace can be restricted by setting the selinux deny_ptrace boolean.
+#
+# For a quick test on your system, try:
+# $ cat /proc/$$/mem > /dev/null
+# cat: /proc/4907/mem: Permission denied
+# Getting "Permission denied" means access failed, "Input/output error" means access succeeded.
+
+import ctypes
+import ctypes.util
+import os
+import sys
+from typing import Optional
+
+from .logging import get_logger
+
+
+_logger = get_logger(__name__)
+
+PR_GET_DUMPABLE = 3
+PR_SET_DUMPABLE = 4
+
+
+_libc = None # type: Optional[ctypes.CDLL]
+def _load_libc():
+ global _libc
+ if _libc is not None:
+ return
+ #assert sys.platform == "linux", sys.platform
+ # note: find_library can raise FileNotFoundError(OSError), see https://github.com/python/cpython/issues/93094
+ _libc_path = ctypes.util.find_library("c")
+ _libc = ctypes.CDLL(_libc_path, use_errno=True)
+ _libc.prctl.argtypes = (ctypes.c_int, ctypes.c_ulong, ctypes.c_ulong, ctypes.c_ulong, ctypes.c_ulong)
+ _libc.prctl.restype = ctypes.c_int
+
+
+def set_dumpable(flag: bool) -> None:
+ """Set the "dumpable" attribute on the current process.
+ This controls whether a core dump will be produced if the process receives a signal whose
+ default behavior is to produce a core dump.
+ In addition, processes that are not dumpable cannot be attached with ptrace() PTRACE_ATTACH.
+
+ In effect, another process running as the same user as us can read our memory if we are dumpable.
+ """
+ _load_libc()
+ res = _libc.prctl(PR_SET_DUMPABLE, int(bool(flag)), 0, 0, 0)
+ if res < 0:
+ eno = ctypes.get_errno()
+ raise OSError(eno, os.strerror(eno), None, None, None)
+
+
+def set_dumpable_safe(flag: bool) -> None:
+ try:
+ _load_libc()
+ except Exception as e:
+ _logger.exception("error loading libc")
+ return
+ assert _libc is not None
+ try:
+ set_dumpable(flag)
+ except OSError as e:
+ _logger.error(f"libc.prctl(PR_SET_DUMPABLE, {flag}) errored: {e}")
+
+
+def get_dumpable() -> bool:
+ _load_libc()
+ res = _libc.prctl(PR_GET_DUMPABLE, 0, 0, 0, 0)
+ if res < 0:
+ eno = ctypes.get_errno()
+ raise OSError(eno, os.strerror(eno), None, None, None)
+ return res != 0
diff --git a/electrum/hw_wallet/__init__.py b/electrum/hw_wallet/__init__.py
new file mode 100644
index 000000000000..8fd80678302b
--- /dev/null
+++ b/electrum/hw_wallet/__init__.py
@@ -0,0 +1,2 @@
+from .plugin import HW_PluginBase, HardwareClientBase, HardwareHandlerBase
+from .cmdline import CmdLineHandler
diff --git a/electrum/hw_wallet/cmdline.py b/electrum/hw_wallet/cmdline.py
new file mode 100644
index 000000000000..367af725533d
--- /dev/null
+++ b/electrum/hw_wallet/cmdline.py
@@ -0,0 +1,77 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2025 The Electrum Developers
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+from electrum.util import print_stderr, raw_input
+from electrum.logging import get_logger
+
+from .plugin import HardwareHandlerBase
+
+
+_logger = get_logger(__name__)
+
+
+class CmdLineHandler(HardwareHandlerBase):
+
+ def get_passphrase(self, msg, confirm):
+ import getpass
+ print_stderr(msg)
+ return getpass.getpass('')
+
+ def get_pin(self, msg, *, show_strength=True):
+ t = {'a': '7', 'b': '8', 'c': '9', 'd': '4', 'e': '5', 'f': '6', 'g': '1', 'h': '2', 'i': '3'}
+ t.update({str(i): str(i) for i in range(1, 10)}) # sneakily also support numpad-conversion
+ print_stderr(msg)
+ print_stderr("a b c\nd e f\ng h i\n-----")
+ o = raw_input()
+ try:
+ return ''.join(map(lambda x: t[x], o))
+ except KeyError as e:
+ raise Exception("Character {} not in matrix!".format(e)) from e
+
+ def prompt_auth(self, msg):
+ import getpass
+ print_stderr(msg)
+ response = getpass.getpass('')
+ if len(response) == 0:
+ return None
+ return response
+
+ def yes_no_question(self, msg):
+ print_stderr(msg)
+ return raw_input() in 'yY'
+
+ def stop(self):
+ pass
+
+ def show_message(self, msg, on_cancel=None):
+ print_stderr(msg)
+
+ def show_error(self, msg, blocking=False):
+ print_stderr(msg)
+
+ def update_status(self, b):
+ _logger.info(f'hw device status {b}')
+
+ def finished(self):
+ pass
diff --git a/electrum/hw_wallet/plugin.py b/electrum/hw_wallet/plugin.py
new file mode 100644
index 000000000000..aea96aae17f7
--- /dev/null
+++ b/electrum/hw_wallet/plugin.py
@@ -0,0 +1,407 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2025 The Electrum Developers
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+from abc import abstractmethod, ABC
+from typing import TYPE_CHECKING, Sequence, Optional, Type, Iterable, Any
+
+from electrum.plugin import (BasePlugin, hook, Device, DeviceMgr,
+ assert_runs_in_hwd_thread, runs_in_hwd_thread)
+from electrum.i18n import _
+from electrum.bitcoin import is_address, opcodes
+from electrum.util import versiontuple, UserFacingException, ChoiceItem
+from electrum.transaction import TxOutput, PartialTransaction
+from electrum.bip32 import BIP32Node
+from electrum.storage import get_derivation_used_for_hw_device_encryption
+from electrum.keystore import Xpub, Hardware_KeyStore
+
+if TYPE_CHECKING:
+ import threading
+ from electrum.plugin import DeviceInfo
+ from electrum.wallet import Abstract_Wallet
+ from electrum.wizard import AbstractWizard
+
+
+class HW_PluginBase(BasePlugin, ABC):
+ keystore_class: Type['Hardware_KeyStore']
+ libraries_available: bool
+ SUPPORTED_XTYPES = ()
+
+ # define supported library versions: minimum_library <= x < maximum_library
+ minimum_library = (0,)
+ maximum_library = (float('inf'),)
+
+ DEVICE_IDS: Iterable[Any]
+
+ def __init__(self, parent, config, name):
+ BasePlugin.__init__(self, parent, config, name)
+ self.device = self.keystore_class.device
+ self.keystore_class.plugin = self
+ self._ignore_outdated_fw = False
+
+ def is_enabled(self):
+ return True
+
+ def device_manager(self) -> 'DeviceMgr':
+ return self.parent.device_manager
+
+ def create_device_from_hid_enumeration(self, d: dict, *, product_key) -> Optional['Device']:
+ # note: id_ needs to be unique between simultaneously connected devices,
+ # and ideally unchanged while a device is connected.
+ # Older versions of hid don't provide interface_number
+ interface_number = d.get('interface_number', -1)
+ usage_page = d['usage_page']
+ # id_=str(d['path']) in itself might be sufficient, but this had to be touched
+ # a number of times already, so let's just go for the overkill approach:
+ id_ = f"{d['path']},{d['serial_number']},{interface_number},{usage_page}"
+ device = Device(path=d['path'],
+ interface_number=interface_number,
+ id_=id_,
+ product_key=product_key,
+ usage_page=usage_page,
+ transport_ui_string='hid')
+ return device
+
+ @hook
+ def close_wallet(self, wallet: 'Abstract_Wallet'):
+ for keystore in wallet.get_keystores():
+ if isinstance(keystore, self.keystore_class):
+ self.device_manager().unpair_pairing_code(keystore.pairing_code())
+ if keystore.thread:
+ keystore.thread.stop()
+
+ def get_client(self, keystore: 'Hardware_KeyStore', force_pair: bool = True, *,
+ devices: Sequence['Device'] = None,
+ allow_user_interaction: bool = True) -> Optional['HardwareClientBase']:
+ devmgr = self.device_manager()
+ handler = keystore.handler
+ client = devmgr.client_for_keystore(self, handler, keystore, force_pair,
+ devices=devices,
+ allow_user_interaction=allow_user_interaction)
+ return client
+
+ def show_address(self, wallet: 'Abstract_Wallet', address, keystore: 'Hardware_KeyStore' = None):
+ pass # implemented in child classes
+
+ def show_address_helper(self, wallet, address, keystore=None):
+ if keystore is None:
+ keystore = wallet.get_keystore()
+ if not is_address(address):
+ keystore.handler.show_error(_('Invalid Bitcoin Address'))
+ return False
+ if not wallet.is_mine(address):
+ keystore.handler.show_error(_('Address not in wallet.'))
+ return False
+ if type(keystore) != self.keystore_class:
+ return False
+ return True
+
+ def get_library_version(self) -> str:
+ """Returns the version of the 3rd party python library
+ for the hw wallet. For example '0.9.0'
+
+ Returns 'unknown' if library is found but cannot determine version.
+ Raises 'ImportError' if library is not found.
+ Raises 'LibraryFoundButUnusable' if found but there was some problem (includes version num).
+ """
+ raise NotImplementedError()
+
+ def check_libraries_available(self) -> bool:
+ def version_str(t):
+ return ".".join(str(i) for i in t)
+
+ try:
+ # this might raise ImportError or LibraryFoundButUnusable
+ library_version = self.get_library_version()
+ # if no exception so far, we might still raise LibraryFoundButUnusable
+ if (library_version == 'unknown'
+ or versiontuple(library_version) < self.minimum_library
+ or versiontuple(library_version) >= self.maximum_library):
+ raise LibraryFoundButUnusable(library_version=library_version)
+ except ImportError as e:
+ self.libraries_available_message = (
+ _("Missing libraries for {}.").format(self.name)
+ + f"\n {e!r}"
+ )
+ return False
+ except LibraryFoundButUnusable as e:
+ library_version = e.library_version
+ self.libraries_available_message = (
+ _("Library version for '{}' is incompatible.").format(self.name)
+ + '\nInstalled: {}, Needed: {} <= x < {}'
+ .format(library_version, version_str(self.minimum_library), version_str(self.maximum_library)))
+ self.logger.warning(self.libraries_available_message)
+ return False
+
+ return True
+
+ def get_library_not_available_message(self) -> str:
+ if hasattr(self, 'libraries_available_message'):
+ message = self.libraries_available_message
+ else:
+ message = _("Missing libraries for {}.").format(self.name)
+ message += '\n' + _("Make sure you install it with python3")
+ return message
+
+ def set_ignore_outdated_fw(self):
+ self._ignore_outdated_fw = True
+
+ def is_outdated_fw_ignored(self) -> bool:
+ return self._ignore_outdated_fw
+
+ def create_client(self, device: 'Device',
+ handler: Optional['HardwareHandlerBase']) -> Optional['HardwareClientBase']:
+ raise NotImplementedError()
+
+ def create_handler(self, window) -> 'HardwareHandlerBase':
+ # note: in Qt GUI, 'window' is either an ElectrumWindow or an QENewWalletWizard
+ raise NotImplementedError()
+
+ def can_recognize_device(self, device: Device) -> bool:
+ """Whether the plugin thinks it can handle the given device.
+ Used for filtering all connected hardware devices to only those by this vendor.
+ """
+ return device.product_key in self.DEVICE_IDS
+
+ @abstractmethod
+ def wizard_entry_for_device(self, device_info: 'DeviceInfo', *, new_wallet: bool) -> str:
+ """Return view name for device
+ """
+ pass
+
+ @hook
+ def init_wallet_wizard(self, wizard: 'AbstractWizard') -> None:
+ self.extend_wizard(wizard)
+
+ @abstractmethod
+ def extend_wizard(self, wizard: 'AbstractWizard') -> None:
+ pass
+
+
+class HardwareClientBase(ABC):
+ handler = None # type: Optional['HardwareHandlerBase']
+
+ def __init__(self, *, plugin: 'HW_PluginBase'):
+ assert_runs_in_hwd_thread()
+ self.plugin = plugin
+
+ def device_manager(self) -> 'DeviceMgr':
+ return self.plugin.device_manager()
+
+ @abstractmethod
+ def is_pairable(self) -> bool:
+ pass
+
+ @abstractmethod
+ def close(self):
+ pass
+
+ def timeout(self, cutoff) -> None: # noqa: B027
+ pass
+
+ @abstractmethod
+ def is_initialized(self) -> bool:
+ """True if initialized, False if wiped."""
+ pass
+
+ def label(self) -> Optional[str]:
+ """The name given by the user to the device.
+
+ Note: labels are shown to the user to help distinguish their devices,
+ and they are also used as a fallback to distinguish devices programmatically.
+ So ideally, different devices would have different labels.
+ """
+ # When returning a constant here (i.e. not implementing the method in the way
+ # it is supposed to work), make sure the return value is in electrum.plugin.PLACEHOLDER_HW_CLIENT_LABELS
+ return " "
+
+ def get_soft_device_id(self) -> Optional[str]:
+ """An id-like string that is used to distinguish devices programmatically.
+ This is a long term id for the device, that does not change between reconnects.
+ This method should not prompt the user, i.e. no user interaction, as it is used
+ during USB device enumeration (called for each unpaired device).
+ Stored in the wallet file.
+ """
+ root_fp = self.request_root_fingerprint_from_device()
+ return root_fp
+
+ @abstractmethod
+ def has_usable_connection_with_device(self) -> bool:
+ pass
+
+ @abstractmethod
+ def get_xpub(self, bip32_path: str, xtype) -> str:
+ pass
+
+ @runs_in_hwd_thread
+ def request_root_fingerprint_from_device(self) -> str:
+ # digitalbitbox (at least) does not reveal xpubs corresponding to unhardened paths
+ # so ask for a direct child, and read out fingerprint from that:
+ child_of_root_xpub = self.get_xpub("m/0'", xtype='standard')
+ root_fingerprint = BIP32Node.from_xkey(child_of_root_xpub).fingerprint.hex().lower()
+ return root_fingerprint
+
+ @runs_in_hwd_thread
+ def get_password_for_storage_encryption(self) -> str:
+ # note: using a different password based on hw device type is highly undesirable! see #5993
+ derivation = get_derivation_used_for_hw_device_encryption()
+ xpub = self.get_xpub(derivation, "standard")
+ password = Xpub.get_pubkey_from_xpub(xpub, ()).hex()
+ return password
+
+ def device_model_name(self) -> Optional[str]:
+ """Return the name of the model of this device, which might be displayed in the UI.
+ E.g. for Trezor, "Trezor One" or "Trezor T".
+ If this method is not defined for a plugin, the plugin name is used as default
+ """
+ return self.plugin.name
+
+
+class HardwareClientDummy(HardwareClientBase):
+ """Hw device we recognize but do not support.
+ E.g. for Ledger HW.1 devices that we used to support in the past, but no longer do.
+ This allows showing an error message to the user.
+ """
+ def __init__(self, *, plugin: 'HW_PluginBase', error_text: str):
+ HardwareClientBase.__init__(self, plugin=plugin)
+ self.error_text = error_text
+
+ def get_xpub(self, bip32_path: str, xtype) -> str:
+ raise Exception(self.error_text)
+
+ def is_pairable(self) -> bool:
+ return False
+
+ def close(self):
+ pass
+
+ def is_initialized(self) -> bool:
+ """True if initialized, False if wiped."""
+ return True
+
+ def label(self) -> Optional[str]:
+ return "dummy_client"
+
+ def has_usable_connection_with_device(self) -> bool:
+ return True
+
+
+class HardwareHandlerBase:
+ """An interface between the GUI and the device handling logic for handling I/O."""
+ win = None
+ device: str
+
+ def get_wallet(self) -> Optional['Abstract_Wallet']:
+ if self.win is not None:
+ if hasattr(self.win, 'wallet'):
+ return self.win.wallet
+
+ def get_gui_thread(self) -> Optional['threading.Thread']:
+ if self.win is not None:
+ if hasattr(self.win, 'gui_thread'):
+ return self.win.gui_thread
+
+ def update_status(self, paired: bool) -> None:
+ pass
+
+ def query_choice(self, msg: str, choices: Sequence[ChoiceItem]) -> Optional[Any]:
+ """Returns ChoiceItem.key (for selected item), or None if the user cancels the dialog."""
+ raise NotImplementedError()
+
+ def yes_no_question(self, msg: str) -> bool:
+ raise NotImplementedError()
+
+ def show_message(self, msg: str, on_cancel=None) -> None:
+ raise NotImplementedError()
+
+ def show_error(self, msg: str, blocking: bool = False) -> None:
+ raise NotImplementedError()
+
+ def finished(self) -> None:
+ pass
+
+ def get_word(self, msg: str) -> str:
+ raise NotImplementedError()
+
+ def get_passphrase(self, msg: str, confirm: bool) -> Optional[str]:
+ raise NotImplementedError()
+
+ def get_pin(self, msg: str, *, show_strength: bool = True) -> str:
+ raise NotImplementedError()
+
+
+def is_any_tx_output_on_change_branch(tx: PartialTransaction) -> bool:
+ return any([txout.is_change for txout in tx.outputs()])
+
+
+def trezor_validate_op_return_output_and_get_data(output: TxOutput) -> bytes:
+ validate_op_return_output(output)
+ script = output.scriptpubkey
+ if not (script[0] == opcodes.OP_RETURN and
+ script[1] == len(script) - 2 and script[1] <= 75):
+ raise UserFacingException(_("Only OP_RETURN scripts, with one constant push, are supported."))
+ return script[2:]
+
+
+def validate_op_return_output(output: TxOutput, *, max_size: int = None) -> None:
+ script = output.scriptpubkey
+ if script[0] != opcodes.OP_RETURN:
+ raise UserFacingException(_("Only OP_RETURN scripts are supported."))
+ if max_size is not None and len(script) > max_size:
+ raise UserFacingException(_("OP_RETURN payload too large." + "\n"
+ + f"(scriptpubkey size {len(script)} > {max_size})"))
+ if output.value != 0:
+ raise UserFacingException(_("Amount for OP_RETURN output must be zero."))
+
+
+def only_hook_if_libraries_available(func):
+ # note: this decorator must wrap @hook, not the other way around,
+ # as 'hook' uses the name of the function it wraps
+ def wrapper(self: 'HW_PluginBase', *args, **kwargs):
+ if not self.libraries_available: return None
+ return func(self, *args, **kwargs)
+ return wrapper
+
+
+class LibraryFoundButUnusable(Exception):
+ def __init__(self, library_version='unknown'):
+ self.library_version = library_version
+
+
+class OutdatedHwFirmwareException(UserFacingException):
+
+ def text_ignore_old_fw_and_continue(self) -> str:
+ suffix = (_("The firmware of your hardware device is too old. "
+ "If possible, you should upgrade it. "
+ "You can ignore this error and try to continue, however things are likely to break.") + "\n\n" +
+ _("Ignore and continue?"))
+ if str(self):
+ return str(self) + "\n\n" + suffix
+ else:
+ return suffix
+
+
+class OperationCancelled(UserFacingException):
+ """Emitted when an operation is cancelled by user on a HW device
+ """
+ pass
diff --git a/electrum/hw_wallet/qt.py b/electrum/hw_wallet/qt.py
new file mode 100644
index 000000000000..5cef3e175441
--- /dev/null
+++ b/electrum/hw_wallet/qt.py
@@ -0,0 +1,321 @@
+#!/usr/bin/env python3
+# -*- mode: python -*-
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2016 The Electrum developers
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import threading
+from functools import partial
+from typing import TYPE_CHECKING, Union, Optional, Sequence
+
+from PyQt6.QtCore import QObject, pyqtSignal, Qt
+from PyQt6.QtWidgets import QVBoxLayout, QLineEdit, QHBoxLayout, QLabel, QMenu
+
+from electrum.i18n import _
+from electrum.logging import Logger
+from electrum.util import UserCancelled, UserFacingException, ChoiceItem
+from electrum.plugin import hook
+
+from electrum.gui.common_qt.util import TaskThread
+from electrum.gui.qt.password_dialog import PasswordLayout, PW_PASSPHRASE
+from electrum.gui.qt.util import (
+ read_QIcon, WWLabel, OkButton, WindowModalDialog, Buttons, CancelButton, char_width_in_lineedit, PasswordLineEdit,
+ read_QIcon_from_bytes
+)
+from electrum.gui.qt.main_window import StatusBarButton
+
+
+from .plugin import OutdatedHwFirmwareException, HW_PluginBase, HardwareHandlerBase
+
+if TYPE_CHECKING:
+ from electrum.wallet import Abstract_Wallet
+ from electrum.keystore import Hardware_KeyStore
+ from electrum.gui.qt import ElectrumWindow
+ from electrum.gui.qt.wizard.wallet import QENewWalletWizard
+
+
+# The trickiest thing about this handler was getting windows properly
+# parented on macOS.
+class QtHandlerBase(HardwareHandlerBase, QObject, Logger):
+ """An interface between the GUI (here, QT) and the device handling
+ logic for handling I/O."""
+
+ passphrase_signal = pyqtSignal(object, object)
+ message_signal = pyqtSignal(object, object)
+ error_signal = pyqtSignal(object, object)
+ word_signal = pyqtSignal(object)
+ clear_signal = pyqtSignal()
+ query_signal = pyqtSignal(object, object)
+ yes_no_signal = pyqtSignal(object)
+ status_signal = pyqtSignal(object)
+
+ def __init__(self, win: Union['ElectrumWindow', 'QENewWalletWizard'], device: str):
+ QObject.__init__(self)
+ Logger.__init__(self)
+ assert win.gui_thread == threading.current_thread(), 'must be called from GUI thread'
+ self.clear_signal.connect(self.clear_dialog)
+ self.error_signal.connect(self.error_dialog)
+ self.message_signal.connect(self.message_dialog)
+ self.passphrase_signal.connect(self.passphrase_dialog)
+ self.word_signal.connect(self.word_dialog)
+ self.query_signal.connect(self.win_query_choice)
+ self.yes_no_signal.connect(self.win_yes_no_question)
+ self.status_signal.connect(self._update_status)
+ self.win = win
+ self.device = device
+ self.dialog = None
+ self.done = threading.Event()
+
+ def top_level_window(self):
+ return self.win.top_level_window()
+
+ def update_status(self, paired):
+ self.status_signal.emit(paired)
+
+ def _update_status(self, paired):
+ if hasattr(self, 'button'):
+ button = self.button
+ icon_bytes = button.icon_paired if paired else button.icon_unpaired
+ icon = read_QIcon_from_bytes(icon_bytes)
+ button.setIcon(icon)
+
+ def query_choice(self, msg: str, choices: Sequence[ChoiceItem]):
+ self.done.clear()
+ self.query_signal.emit(msg, choices)
+ self.done.wait()
+ return self.choice
+
+ def yes_no_question(self, msg):
+ self.done.clear()
+ self.yes_no_signal.emit(msg)
+ self.done.wait()
+ return self.ok
+
+ def show_message(self, msg, on_cancel=None):
+ self.message_signal.emit(msg, on_cancel)
+
+ def show_error(self, msg, blocking=False):
+ self.done.clear()
+ self.error_signal.emit(msg, blocking)
+ if blocking:
+ self.done.wait()
+
+ def finished(self):
+ self.clear_signal.emit()
+
+ def get_word(self, msg):
+ self.done.clear()
+ self.word_signal.emit(msg)
+ self.done.wait()
+ return self.word
+
+ def get_passphrase(self, msg, confirm):
+ self.done.clear()
+ self.passphrase_signal.emit(msg, confirm)
+ self.done.wait()
+ return self.passphrase
+
+ def passphrase_dialog(self, msg, confirm):
+ # If confirm is true, require the user to enter the passphrase twice
+ parent = self.top_level_window()
+ d = WindowModalDialog(parent, _("Enter Passphrase"))
+ if confirm:
+ OK_button = OkButton(d)
+ playout = PasswordLayout(msg=msg, kind=PW_PASSPHRASE, OK_button=OK_button)
+ vbox = QVBoxLayout()
+ vbox.addLayout(playout.layout())
+ vbox.addLayout(Buttons(CancelButton(d), OK_button))
+ d.setLayout(vbox)
+ passphrase = playout.new_password() if d.exec() else None
+ else:
+ pw = PasswordLineEdit()
+ pw.setMinimumWidth(200)
+ vbox = QVBoxLayout()
+ vbox.addWidget(WWLabel(msg))
+ vbox.addWidget(pw)
+ vbox.addLayout(Buttons(CancelButton(d), OkButton(d)))
+ d.setLayout(vbox)
+ passphrase = pw.text() if d.exec() else None
+ self.passphrase = passphrase
+ self.done.set()
+
+ def word_dialog(self, msg):
+ dialog = WindowModalDialog(self.top_level_window(), "")
+ hbox = QHBoxLayout(dialog)
+ hbox.addWidget(QLabel(msg))
+ text = QLineEdit()
+ text.setMaximumWidth(12 * char_width_in_lineedit())
+ text.returnPressed.connect(dialog.accept)
+ hbox.addWidget(text)
+ hbox.addStretch(1)
+ dialog.exec() # Firmware cannot handle cancellation
+ self.word = text.text()
+ self.done.set()
+
+ MESSAGE_DIALOG_TITLE = None # type: Optional[str]
+ def message_dialog(self, msg, on_cancel=None):
+ self.clear_dialog()
+ title = self.MESSAGE_DIALOG_TITLE
+ if title is None:
+ title = _('Please check your {} device').format(self.device)
+ self.dialog = dialog = WindowModalDialog(self.top_level_window(), title)
+ label = QLabel(msg)
+ label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
+ vbox = QVBoxLayout(dialog)
+ vbox.addWidget(label)
+ if on_cancel:
+ dialog.rejected.connect(on_cancel)
+ vbox.addLayout(Buttons(CancelButton(dialog)))
+ dialog.show()
+
+ def error_dialog(self, msg, blocking):
+ self.win.show_error(msg, parent=self.top_level_window())
+ if blocking:
+ self.done.set()
+
+ def clear_dialog(self):
+ if self.dialog:
+ self.dialog.accept()
+ self.dialog = None
+
+ def win_query_choice(self, msg: str, choices: Sequence[ChoiceItem]):
+ try:
+ self.choice = self.win.query_choice(msg, choices)
+ except UserCancelled:
+ self.choice = None
+ self.done.set()
+
+ def win_yes_no_question(self, msg):
+ self.ok = self.win.question(msg)
+ self.done.set()
+
+
+class QtPluginBase(object):
+
+ @hook
+ def load_wallet(self: Union['QtPluginBase', HW_PluginBase], wallet: 'Abstract_Wallet', window: 'ElectrumWindow'):
+ relevant_keystores = [keystore for keystore in wallet.get_keystores()
+ if isinstance(keystore, self.keystore_class)]
+ if not relevant_keystores:
+ return
+ for keystore in relevant_keystores:
+ if not self.libraries_available:
+ message = keystore.plugin.get_library_not_available_message()
+ window.show_error(message)
+ return
+ tooltip = self.device + '\n' + (keystore.label or 'unnamed')
+ cb = partial(self._on_status_bar_button_click, window=window, keystore=keystore)
+ sb = window.statusBar()
+ icon = read_QIcon_from_bytes(self.read_file(self.icon_unpaired))
+ button = StatusBarButton(icon, tooltip, cb, sb.height())
+ button.icon_paired = self.read_file(self.icon_paired)
+ button.icon_unpaired = self.read_file(self.icon_unpaired)
+ sb.addPermanentWidget(button)
+ handler = self.create_handler(window)
+ handler.button = button
+ keystore.handler = handler
+ keystore.thread = TaskThread(window, on_error=partial(self.on_task_thread_error, window, keystore))
+ self.add_show_address_on_hw_device_button_for_receive_addr(wallet, keystore, window)
+ # Trigger pairings
+ devmgr = self.device_manager()
+ trigger_pairings = partial(devmgr.trigger_pairings, relevant_keystores, allow_user_interaction=True)
+ some_keystore = relevant_keystores[0]
+ some_keystore.thread.add(trigger_pairings)
+
+ def _on_status_bar_button_click(self, *, window: 'ElectrumWindow', keystore: 'Hardware_KeyStore'):
+ try:
+ self.show_settings_dialog(window=window, keystore=keystore)
+ except (UserFacingException, UserCancelled) as e:
+ exc_info = (type(e), e, e.__traceback__)
+ self.on_task_thread_error(window=window, keystore=keystore, exc_info=exc_info)
+
+ def on_task_thread_error(self: Union['QtPluginBase', HW_PluginBase], window: 'ElectrumWindow',
+ keystore: 'Hardware_KeyStore', exc_info):
+ e = exc_info[1]
+ if isinstance(e, OutdatedHwFirmwareException):
+ if window.question(e.text_ignore_old_fw_and_continue(), title=_("Outdated device firmware")):
+ self.set_ignore_outdated_fw()
+ # will need to re-pair
+ devmgr = self.device_manager()
+
+ def re_pair_device():
+ device_id = self.choose_device(window, keystore)
+ devmgr.unpair_id(device_id)
+ self.get_client(keystore)
+
+ keystore.thread.add(re_pair_device)
+ return
+ else:
+ window.on_error(exc_info)
+
+ def choose_device(self: Union['QtPluginBase', HW_PluginBase], window: 'ElectrumWindow',
+ keystore: 'Hardware_KeyStore') -> Optional[str]:
+ """This dialog box should be usable even if the user has
+ forgotten their PIN or it is in bootloader mode."""
+ assert window.gui_thread != threading.current_thread(), 'must not be called from GUI thread'
+ device_id = self.device_manager().id_by_pairing_code(keystore.pairing_code())
+ if not device_id:
+ try:
+ info = self.device_manager().select_device(self, keystore.handler, keystore)
+ except UserCancelled:
+ return
+ device_id = info.device.id_
+ return device_id
+
+ def show_settings_dialog(self, window: 'ElectrumWindow', keystore: 'Hardware_KeyStore') -> None:
+ # default implementation (if no dialog): just try to connect to device
+ def connect():
+ device_id = self.choose_device(window, keystore)
+
+ keystore.thread.add(connect)
+
+ def add_show_address_on_hw_device_button_for_receive_addr(
+ self,
+ wallet: 'Abstract_Wallet',
+ keystore: 'Hardware_KeyStore',
+ main_window: 'ElectrumWindow'
+ ):
+ plugin = keystore.plugin
+ receive_tab = main_window.receive_tab
+
+ def show_address():
+ addr = str(receive_tab.addr)
+ keystore.thread.add(partial(plugin.show_address, wallet, addr, keystore))
+
+ dev_name = f"{plugin.device} ({keystore.label})"
+ receive_tab.toolbar_menu.addAction(read_QIcon("eye1.png"), _("Show address on {}").format(dev_name), show_address)
+
+ def create_handler(self, window: Union['ElectrumWindow', 'QENewWalletWizard']) -> 'QtHandlerBase':
+ raise NotImplementedError()
+
+ def _add_menu_action(self, menu: QMenu, address: str, wallet: 'Abstract_Wallet'):
+ if not wallet.is_mine(address):
+ return
+ for keystore in wallet.get_keystores():
+ if type(keystore) == self.keystore_class:
+
+ def show_address(keystore=keystore):
+ keystore.thread.add(partial(self.show_address, wallet, address, keystore=keystore))
+
+ device_name = "{} ({})".format(self.device, keystore.label)
+ menu.addAction(read_QIcon("eye1.png"), _("Show address on {}").format(device_name), show_address)
diff --git a/electrum/hw_wallet/trezor_qt_pinmatrix.py b/electrum/hw_wallet/trezor_qt_pinmatrix.py
new file mode 100644
index 000000000000..5eb9126dc99c
--- /dev/null
+++ b/electrum/hw_wallet/trezor_qt_pinmatrix.py
@@ -0,0 +1,107 @@
+# from https://github.com/trezor/trezor-firmware/blob/3f1d2059ca140788dab8726778f05cedbea20bc4/python/src/trezorlib/qt/pinmatrix.py
+#
+# This file is part of the Trezor project.
+#
+# Copyright (C) 2012-2022 SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see .
+
+import math
+from typing import Any
+
+from PyQt6.QtCore import QRegularExpression, Qt
+from PyQt6.QtGui import QRegularExpressionValidator
+from PyQt6.QtWidgets import (
+ QGridLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QSizePolicy, QVBoxLayout, QWidget
+)
+
+
+class PinButton(QPushButton):
+ def __init__(self, password: QLineEdit, encoded_value: int) -> None:
+ super(PinButton, self).__init__("?")
+ self.password = password
+ self.encoded_value = encoded_value
+
+ self.clicked.connect(self._pressed)
+
+ def _pressed(self) -> None:
+ self.password.setText(self.password.text() + str(self.encoded_value))
+ self.password.setFocus()
+
+
+class PinMatrixWidget(QWidget):
+ """
+ Displays widget with nine blank buttons and password box.
+ Encodes button clicks into sequence of numbers for passing
+ into PinAck messages of Trezor.
+
+ show_strength=True may be useful for entering new PIN
+ """
+
+ def __init__(self, show_strength: bool = True, parent: Any = None) -> None:
+ super(PinMatrixWidget, self).__init__(parent)
+
+ self.password = QLineEdit()
+ self.password.setValidator(QRegularExpressionValidator(QRegularExpression("[1-9]+"), None))
+ self.password.setEchoMode(QLineEdit.EchoMode.Password)
+
+ self.password.textChanged.connect(self._password_changed)
+
+ self.strength = QLabel()
+ self.strength.setMinimumWidth(75)
+ self.strength.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self._set_strength(0)
+
+ grid = QGridLayout()
+ grid.setSpacing(0)
+ for y in range(3)[::-1]:
+ for x in range(3):
+ button = PinButton(self.password, x + y * 3 + 1)
+ button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
+ button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
+ grid.addWidget(button, 3 - y, x)
+
+ hbox = QHBoxLayout()
+ hbox.addWidget(self.password)
+ if show_strength:
+ hbox.addWidget(self.strength)
+
+ vbox = QVBoxLayout()
+ vbox.addLayout(grid)
+ vbox.addLayout(hbox)
+ self.setLayout(vbox)
+
+ def _set_strength(self, strength: float) -> None:
+ if strength < 3000:
+ self.strength.setText("weak")
+ self.strength.setStyleSheet("QLabel { color : #d00; }")
+ elif strength < 60000:
+ self.strength.setText("fine")
+ self.strength.setStyleSheet("QLabel { color : #db0; }")
+ elif strength < 360000:
+ self.strength.setText("strong")
+ self.strength.setStyleSheet("QLabel { color : #0a0; }")
+ else:
+ self.strength.setText("ULTIMATE")
+ self.strength.setStyleSheet("QLabel { color : #000; font-weight: bold;}")
+
+ def _password_changed(self, password: Any) -> None:
+ self._set_strength(self.get_strength())
+
+ def get_strength(self) -> float:
+ digits = len(set(str(self.password.text())))
+ strength = math.factorial(9) / math.factorial(9 - digits)
+ return strength
+
+ def get_value(self) -> str:
+ return self.password.text()
diff --git a/electrum/i18n.py b/electrum/i18n.py
new file mode 100644
index 000000000000..923d82e0ce2b
--- /dev/null
+++ b/electrum/i18n.py
@@ -0,0 +1,222 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2012 thomasv@gitorious
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+import functools
+import json
+import os
+import string
+from typing import Optional
+
+import gettext
+
+from .logging import get_logger
+
+
+_logger = get_logger(__name__)
+LOCALE_DIR = os.path.join(os.path.dirname(__file__), 'locale', 'locale')
+
+
+def _get_null_translations():
+ """Returns a gettext Translations obj with translations explicitly disabled."""
+ return gettext.translation('electrum', fallback=True, class_=gettext.NullTranslations)
+
+
+# Set initial default language to None. i.e. translations explicitly disabled.
+# The main script or GUIs can call set_language to enable translations.
+_language = _get_null_translations()
+
+
+def _ensure_translation_keeps_format_string_syntax_similar(translator):
+ """This checks that the source string is syntactically similar to the translated string.
+ If not, translations are rejected by falling back to the source string.
+ """
+ sf = string.Formatter()
+ @functools.wraps(translator)
+ def safe_translator(msg: str, **kwargs):
+ translation = translator(msg, **kwargs)
+ parsed1 = list(sf.parse(msg)) # iterable of tuples (literal_text, field_name, format_spec, conversion)
+ try:
+ parsed2 = list(sf.parse(translation))
+ except ValueError: # malformed format string in translation
+ _logger.warning(
+ f"rejected translation string: failed to parse. original={msg!r}. {translation=!r}",
+ only_once=True)
+ return msg
+ # num of replacement fields must match:
+ if len(parsed1) != len(parsed2):
+ _logger.warning(
+ f"rejected translation string: num replacement fields mismatch. original={msg!r}. {translation=!r}",
+ only_once=True)
+ return msg
+ # set of "field_name"s must not change. (re-ordering is explicitly allowed):
+ field_names1 = set(tupl[1] for tupl in parsed1)
+ field_names2 = set(tupl[1] for tupl in parsed2)
+ if field_names1 != field_names2:
+ _logger.warning(
+ f"rejected translation string: set of field_names mismatch. original={msg!r}. {translation=!r}",
+ only_once=True)
+ return msg
+ # checks done.
+ return translation
+ return safe_translator
+
+
+# note: do not use old-style (%) formatting inside translations,
+# as syntactically incorrectly translated strings often raise exceptions (see #3237).
+# e.g. consider _("Connected to %d nodes.") % n # <- raises. do NOT use
+# >>> "Connecté aux noeuds" % n
+# TypeError: not all arguments converted during string formatting
+# note: f-strings cannot be translated! see https://stackoverflow.com/q/49797658
+# So this does NOT work: _(f"My name: {name}") # <- cannot be translated. do NOT use
+# instead use .format: _("My name: {}").format(name) # <- works. prefer this way.
+# note: positional and keyword-based substitution also works with str.format().
+# These give more flexibility to translators: it allows reordering the substituted values.
+# However, only if the translators understand and use it correctly!
+# _("time left: {0} minutes, {1} seconds").format(t//60, t%60) # <- works. ok to use
+# _("time left: {mins} minutes, {secs} seconds").format(mins=t//60, secs=t%60) # <- works, but too complex
+@_ensure_translation_keeps_format_string_syntax_similar
+def _(msg: str, *, context=None) -> str:
+ if msg == "":
+ return "" # empty string must not be translated. see #7158
+ if context:
+ contexts = [context]
+ if context[-1] != "|": # try with both "|" suffix and without
+ contexts.append(context + "|")
+ else:
+ contexts.append(context[:-1])
+ for ctx in contexts:
+ out = _language.pgettext(ctx, msg)
+ if out != msg: # found non-trivial translation
+ return out
+ # else try without context
+ return _language.gettext(msg)
+
+
+def set_language(x: Optional[str]) -> None:
+ _logger.info(f"setting language to {x!r}")
+ global _language
+ if not x:
+ return
+ if x.startswith("en_"):
+ # Setting the language to "English" is a protected special-case:
+ # we disable all translations and use the source strings.
+ _language = _get_null_translations()
+ else:
+ _language = gettext.translation('electrum', LOCALE_DIR, fallback=True, languages=[x])
+
+
+# note: The values (human-visible lang names) should be either in English or in their own lang,
+# but NOT translated to the currently selected lang.
+# e.g. "fr_FR" we could show as either "French" or "Francais", or even as "French - Francais",
+# but it is evil to show it as "Franzosisch". How am I supposed to switch back to English from Korean??? :)
+languages = {
+ '': _('Default'),
+ 'ar_SA': 'Arabic',
+ 'bg_BG': 'Bulgarian',
+ 'cs_CZ': 'Czech',
+ 'da_DK': 'Danish',
+ 'de_DE': 'German',
+ 'el_GR': 'Greek',
+ 'eo_UY': 'Esperanto',
+ 'en_UK': 'English', # selecting this guarantees seeing the untranslated source strings
+ 'es_ES': 'Spanish',
+ 'fa_IR': 'Persian',
+ 'fr_FR': 'French',
+ 'hu_HU': 'Hungarian',
+ 'hy_AM': 'Armenian',
+ 'id_ID': 'Indonesian',
+ 'it_IT': 'Italian',
+ 'ja_JP': 'Japanese',
+ 'ky_KG': 'Kyrgyz',
+ 'lv_LV': 'Latvian',
+ 'nb_NO': 'Norwegian Bokmal',
+ 'nl_NL': 'Dutch',
+ 'pl_PL': 'Polish',
+ 'pt_BR': 'Portuguese (Brazil)',
+ 'pt_PT': 'Portuguese',
+ 'ro_RO': 'Romanian',
+ 'ru_RU': 'Russian',
+ 'sk_SK': 'Slovak',
+ 'sl_SI': 'Slovenian',
+ 'sv_SE': 'Swedish',
+ 'ta_IN': 'Tamil',
+ 'th_TH': 'Thai',
+ 'tr_TR': 'Turkish',
+ 'uk_UA': 'Ukrainian',
+ 'vi_VN': 'Vietnamese',
+ 'zh_CN': 'Chinese Simplified',
+ 'zh_TW': 'Chinese Traditional',
+}
+assert '' in languages
+
+
+def get_gui_lang_names(*, show_completion_percent: bool = True) -> dict[str, str]:
+ """Returns a lang_code -> lang_name mapping, sorted.
+
+ If show_completion_percent is True, lang_name includes a % estimate for translation completeness.
+ """
+ # calc catalog sizes
+ if show_completion_percent:
+ stats = _get_stats()
+ # sort ("Default" first, then "English", then lexicographically sorted names)
+ languages_copy = languages.copy()
+ lang_pair_default = ("", languages_copy.pop("")) # pop "Default"
+ lang_pair_english = ("en_UK", languages_copy.pop("en_UK")) # pop "English"
+ lang_pairs_sorted = sorted(languages_copy.items(), key=lambda x: x[1])
+ # fancy names
+ gui_lang_names = {} # type: dict[str, str]
+ gui_lang_names[lang_pair_default[0]] = lang_pair_default[1]
+ gui_lang_names[lang_pair_english[0]] = lang_pair_english[1]
+ for lang_code, lang_name in lang_pairs_sorted:
+ if show_completion_percent and stats:
+ source_str_cnt = max(stats["source_string_count"], 1) # avoid div-by-zero
+ try:
+ lang_data = stats["translations"][lang_code]
+ except KeyError as e:
+ _logger.warning(f"missing language from stats.json: {e!r}")
+ catalog_percent = "??"
+ else:
+ translated_str_cnt = lang_data["string_count"]
+ catalog_percent = round(100 * translated_str_cnt / source_str_cnt)
+ gui_lang_names[lang_code] = f"{lang_name} ({catalog_percent}%)"
+ else:
+ gui_lang_names[lang_code] = lang_name
+ return gui_lang_names
+
+
+_stats = None
+def _get_stats() -> dict:
+ global _stats
+ if _stats is None:
+ fname = f"{LOCALE_DIR}/stats.json"
+ try:
+ with open(fname, "r", encoding="utf-8") as f:
+ text = f.read()
+ except OSError as e: # we tolerate the file missing
+ # This can happen e.g. when running from git clone if user did not run build_locale.sh.
+ _logger.info(f"failed to open stats file {fname!r} - built locale (translations) missing??: {e!r}")
+ _stats = {}
+ else: # found file. if it is there, it MUST parse correctly
+ _stats = json.loads(text)
+ return _stats
diff --git a/electrum/interface.py b/electrum/interface.py
new file mode 100644
index 000000000000..918ead8deff5
--- /dev/null
+++ b/electrum/interface.py
@@ -0,0 +1,1811 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2011 thomasv@gitorious
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+import os
+import re
+import ssl
+import sys
+import time
+import traceback
+import asyncio
+import socket
+from typing import Tuple, Union, List, TYPE_CHECKING, Optional, Set, NamedTuple, Any, Sequence, Dict
+from collections import defaultdict
+from ipaddress import IPv4Network, IPv6Network, ip_address, IPv6Address, IPv4Address
+import itertools
+import logging
+import hashlib
+import functools
+import random
+import enum
+
+import aiorpcx
+from aiorpcx import RPCSession, Notification, NetAddress, NewlineFramer
+from aiorpcx.curio import timeout_after, TaskTimeout
+from aiorpcx.jsonrpc import JSONRPC, CodeMessageError
+from aiorpcx.rawsocket import RSClient, RSTransport
+import certifi
+
+from .util import (ignore_exceptions, log_exceptions, bfh, ESocksProxy,
+ is_integer, is_non_negative_integer, is_hash256_str, is_hex_str,
+ is_int_or_float, is_non_negative_int_or_float, OldTaskGroup,
+ send_exception_to_crash_reporter, error_text_str_to_safe_str, versiontuple)
+from . import util
+from . import x509
+from . import pem
+from . import version
+from . import blockchain
+from .blockchain import Blockchain, HEADER_SIZE, CHUNK_SIZE
+from . import bitcoin
+from .bitcoin import DummyAddress, DummyAddressUsedInTxException
+from . import constants
+from .i18n import _
+from .logging import Logger
+from .transaction import Transaction
+from .fee_policy import FEE_ETA_TARGETS
+from .lrucache import LRUCache
+
+if TYPE_CHECKING:
+ from .network import Network
+ from .simple_config import SimpleConfig
+
+
+ca_path = certifi.where()
+
+BUCKET_NAME_OF_ONION_SERVERS = 'onion'
+
+KNOWN_ELEC_PROTOCOL_TRANSPORTS = {'t', 's'}
+PREFERRED_NETWORK_PROTOCOL = 's'
+assert PREFERRED_NETWORK_PROTOCOL in KNOWN_ELEC_PROTOCOL_TRANSPORTS
+
+MAX_NUM_HEADERS_PER_REQUEST = 2016
+assert MAX_NUM_HEADERS_PER_REQUEST >= CHUNK_SIZE
+
+
+class NetworkTimeout:
+ # seconds
+ class Generic:
+ NORMAL = 30
+ RELAXED = 45
+ MOST_RELAXED = 600
+
+ class Urgent(Generic):
+ NORMAL = 10
+ RELAXED = 20
+ MOST_RELAXED = 60
+
+
+def assert_non_negative_integer(val: Any) -> None:
+ if not is_non_negative_integer(val):
+ raise RequestCorrupted(f'{val!r} should be a non-negative integer')
+
+
+def assert_integer(val: Any) -> None:
+ if not is_integer(val):
+ raise RequestCorrupted(f'{val!r} should be an integer')
+
+
+def assert_int_or_float(val: Any) -> None:
+ if not is_int_or_float(val):
+ raise RequestCorrupted(f'{val!r} should be int or float')
+
+
+def assert_non_negative_int_or_float(val: Any) -> None:
+ if not is_non_negative_int_or_float(val):
+ raise RequestCorrupted(f'{val!r} should be a non-negative int or float')
+
+
+def assert_hash256_str(val: Any) -> None:
+ if not is_hash256_str(val):
+ raise RequestCorrupted(f'{val!r} should be a hash256 str')
+
+
+def assert_hex_str(val: Any) -> None:
+ if not is_hex_str(val):
+ raise RequestCorrupted(f'{val!r} should be a hex str')
+
+
+def assert_dict_contains_field(d: Any, *, field_name: str) -> Any:
+ if not isinstance(d, dict):
+ raise RequestCorrupted(f'{d!r} should be a dict')
+ if field_name not in d:
+ raise RequestCorrupted(f'required field {field_name!r} missing from dict')
+ return d[field_name]
+
+
+def assert_list_or_tuple(val: Any) -> None:
+ if not isinstance(val, (list, tuple)):
+ raise RequestCorrupted(f'{val!r} should be a list or tuple')
+
+
+def protocol_tuple(s: Any) -> tuple[int, ...]:
+ """Converts a protocol version number, such as "1.0" to a tuple (1, 0).
+
+ If the version number is bad, (0, ) indicating version 0 is returned.
+ """
+ try:
+ assert isinstance(s, str)
+ return versiontuple(s)
+ except Exception:
+ return (0, )
+
+
+class ChainResolutionMode(enum.Enum):
+ CATCHUP = enum.auto()
+ BACKWARD = enum.auto()
+ BINARY = enum.auto()
+ FORK = enum.auto()
+ NO_FORK = enum.auto()
+
+
+class NotificationSession(RPCSession):
+
+ def __init__(self, *args, interface: 'Interface', **kwargs):
+ super(NotificationSession, self).__init__(*args, **kwargs)
+ self.subscriptions = defaultdict(list)
+ self.cache = {}
+ self._msg_counter = itertools.count(start=1)
+ self.interface = interface
+ self.taskgroup = interface.taskgroup
+ self.cost_hard_limit = 0 # disable aiorpcx resource limits
+
+ # To log pre-processed json traffic, uncomment:
+ #self.logger.setLevel(logging.DEBUG) # from aiorpcx
+ #self.verbosity = 4
+
+ async def handle_request(self, request):
+ self.maybe_log(f"--> {request}")
+ try:
+ if isinstance(request, Notification):
+ params, result = request.args[:-1], request.args[-1]
+ key = self.get_hashable_key_for_rpc_call(request.method, params)
+ if key in self.subscriptions:
+ self.cache[key] = result
+ for queue in self.subscriptions[key]:
+ await queue.put(request.args)
+ else:
+ raise Exception(f'unexpected notification')
+ else:
+ raise Exception(f'unexpected request. not a notification')
+ except Exception as e:
+ self.interface.logger.info(f"error handling request {request}. exc: {repr(e)}")
+ await self.close()
+
+ async def send_request(self, *args, timeout=None, **kwargs):
+ # note: semaphores/timeouts/backpressure etc are handled by
+ # aiorpcx. the timeout arg here in most cases should not be set
+ msg_id = next(self._msg_counter)
+ self.maybe_log(f"<-- {args} {kwargs} (id: {msg_id})")
+ try:
+ # note: RPCSession.send_request raises TaskTimeout in case of a timeout.
+ # TaskTimeout is a subclass of CancelledError, which is *suppressed* in TaskGroups
+ response = await util.wait_for2(
+ super().send_request(*args, **kwargs),
+ timeout)
+ except (TaskTimeout, asyncio.TimeoutError) as e:
+ self.maybe_log(f"--> request timed out: {args} (id: {msg_id})")
+ raise RequestTimedOut(f'request timed out: {args} (id: {msg_id})') from e
+ except CodeMessageError as e:
+ self.maybe_log(f"--> {repr(e)} (id: {msg_id})")
+ raise
+ except BaseException as e: # cancellations, etc. are useful for debugging
+ self.maybe_log(f"--> {repr(e)} (id: {msg_id})")
+ raise
+ else:
+ self.maybe_log(f"--> {response} (id: {msg_id})")
+ return response
+
+ def set_default_timeout(self, timeout):
+ assert hasattr(self, "sent_request_timeout") # in base class
+ self.sent_request_timeout = timeout
+ assert hasattr(self, "max_send_delay") # in base class
+ self.max_send_delay = timeout
+
+ async def subscribe(self, method: str, params: List, queue: asyncio.Queue):
+ # note: until the cache is written for the first time,
+ # each 'subscribe' call might make a request on the network.
+ key = self.get_hashable_key_for_rpc_call(method, params)
+ self.subscriptions[key].append(queue)
+ if key in self.cache:
+ result = self.cache[key]
+ else:
+ result = await self.send_request(method, params)
+ self.cache[key] = result
+ await queue.put(params + [result])
+
+ def unsubscribe(self, queue):
+ """Unsubscribe a callback to free object references to enable GC."""
+ # note: we can't unsubscribe from the server, so we keep receiving
+ # subsequent notifications
+ for v in self.subscriptions.values():
+ if queue in v:
+ v.remove(queue)
+
+ @classmethod
+ def get_hashable_key_for_rpc_call(cls, method, params):
+ """Hashable index for subscriptions and cache"""
+ return str(method) + repr(params)
+
+ def maybe_log(self, msg: str) -> None:
+ if not self.interface: return
+ if self.interface.debug or self.interface.network.debug:
+ self.interface.logger.debug(msg)
+
+ def default_framer(self):
+ # overridden so that max_size can be customized
+ max_size = self.interface.network.config.NETWORK_MAX_INCOMING_MSG_SIZE
+ assert max_size > 500_000, f"{max_size=} (< 500_000) is too small"
+ return NewlineFramer(max_size=max_size)
+
+ async def close(self, *, force_after: int = None):
+ """Closes the connection and waits for it to be closed.
+ We try to flush buffered data to the wire, which can take some time.
+ """
+ if force_after is None:
+ # We give up after a while and just abort the connection.
+ # Note: specifically if the server is running Fulcrum, waiting seems hopeless,
+ # the connection must be aborted (see https://github.com/cculianu/Fulcrum/issues/76)
+ # Note: if the ethernet cable was pulled or wifi disconnected, that too might
+ # wait until this timeout is triggered
+ force_after = 1 # seconds
+ await super().close(force_after=force_after)
+
+
+class NetworkException(Exception): pass
+
+
+class GracefulDisconnect(NetworkException):
+ log_level = logging.INFO
+
+ def __init__(self, *args, log_level=None, **kwargs):
+ Exception.__init__(self, *args, **kwargs)
+ if log_level is not None:
+ self.log_level = log_level
+
+
+class RequestTimedOut(GracefulDisconnect):
+ def __str__(self):
+ return _("Network request timed out.")
+
+
+class RequestCorrupted(Exception): pass
+
+class ErrorParsingSSLCert(Exception): pass
+class ErrorGettingSSLCertFromServer(Exception): pass
+class ErrorSSLCertFingerprintMismatch(Exception): pass
+class InvalidOptionCombination(Exception): pass
+class ConnectError(NetworkException): pass
+
+
+class TxBroadcastError(NetworkException):
+ def get_message_for_gui(self):
+ raise NotImplementedError()
+
+
+class TxBroadcastHashMismatch(TxBroadcastError):
+ def get_message_for_gui(self):
+ return "{}\n{}\n\n{}" \
+ .format(_("The server returned an unexpected transaction ID when broadcasting the transaction."),
+ _("Consider trying to connect to a different server, or updating Electrum."),
+ str(self))
+
+
+class TxBroadcastServerReturnedError(TxBroadcastError):
+ def get_message_for_gui(self):
+ return "{}\n{}\n\n{}" \
+ .format(_("The server returned an error when broadcasting the transaction."),
+ _("Consider trying to connect to a different server, or updating Electrum."),
+ str(self))
+
+
+class TxBroadcastUnknownError(TxBroadcastError):
+ def get_message_for_gui(self):
+ return "{}\n{}" \
+ .format(_("Unknown error when broadcasting the transaction."),
+ _("Consider trying to connect to a different server, or updating Electrum."))
+
+
+class _RSClient(RSClient):
+ async def create_connection(self):
+ try:
+ return await super().create_connection()
+ except OSError as e:
+ # note: using "from e" here will set __cause__ of ConnectError
+ raise ConnectError(e) from e
+
+
+class PaddedRSTransport(RSTransport):
+ """A raw socket transport that provides basic countermeasures against traffic analysis
+ by padding the jsonrpc payload with whitespaces to have ~uniform-size TCP packets.
+ (it is assumed that a network observer does not see plaintext transport contents,
+ due to it being wrapped e.g. in TLS)
+ """
+
+ MIN_PACKET_SIZE = 1024
+ WAIT_FOR_BUFFER_GROWTH_SECONDS = 1.0
+ # (unpadded) amount of bytes sent instantly before beginning with polling.
+ # This makes the initial handshake where a few small messages are exchanged faster.
+ WARMUP_BUDGET_SIZE = 1024
+
+ session: Optional['RPCSession']
+
+ def __init__(self, *args, **kwargs):
+ RSTransport.__init__(self, *args, **kwargs)
+ self._sbuffer = bytearray() # "send buffer"
+ self._sbuffer_task = None # type: Optional[asyncio.Task]
+ self._sbuffer_has_data_evt = asyncio.Event()
+ self._last_send = time.monotonic()
+ self._force_send = False # type: bool
+
+ # note: this does not call super().write() but is a complete reimplementation
+ async def write(self, message):
+ await self._can_send.wait()
+ if self.is_closing():
+ return
+ framed_message = self._framer.frame(message)
+ self._sbuffer += framed_message
+ self._sbuffer_has_data_evt.set()
+ self._maybe_consume_sbuffer()
+
+ def _maybe_consume_sbuffer(self) -> None:
+ """Maybe take some data from sbuffer and send it on the wire."""
+ if not self._can_send.is_set() or self.is_closing():
+ return
+ buf = self._sbuffer
+ if not buf:
+ return
+ # if there is enough data in the buffer, or if we haven't sent in a while, send now:
+ if not (
+ self._force_send
+ or len(buf) >= self.MIN_PACKET_SIZE
+ or self._last_send + self.WAIT_FOR_BUFFER_GROWTH_SECONDS < time.monotonic()
+ or self.session.send_size < self.WARMUP_BUDGET_SIZE
+ ):
+ return
+ assert buf[-2:] in (b"}\n", b"]\n"), f"unexpected json-rpc terminator: {buf[-2:]=!r}"
+ # either (1) pad length to next power of two, to create "lsize" packet:
+ payload_lsize = len(buf)
+ total_lsize = max(self.MIN_PACKET_SIZE, 2 ** (payload_lsize.bit_length()))
+ npad_lsize = total_lsize - payload_lsize
+ # or if that wasted a lot of bandwidth with padding, (2) defer sending some messages
+ # and create a packet with half that size ("ssize", s for small)
+ total_ssize = max(self.MIN_PACKET_SIZE, total_lsize // 2)
+ payload_ssize = buf.rfind(b"\n", 0, total_ssize)
+ if payload_ssize != -1:
+ payload_ssize += 1 # for "\n" char
+ npad_ssize = total_ssize - payload_ssize
+ else:
+ npad_ssize = float("inf")
+ # decide between (1) and (2):
+ if self._force_send or npad_lsize <= npad_ssize:
+ # (1) create "lsize" packet: consume full buffer
+ npad = npad_lsize
+ p_idx = payload_lsize
+ else:
+ # (2) create "ssize" packet: consume some, but defer some for later
+ npad = npad_ssize
+ p_idx = payload_ssize
+ # pad by adding spaces near end
+ # self.session.maybe_log(
+ # f"PaddedRSTransport. calling low-level write(). "
+ # f"chose between (lsize:{payload_lsize}+{npad_lsize}, ssize:{payload_ssize}+{npad_ssize}). "
+ # f"won: {'tie' if npad_lsize == npad_ssize else 'lsize' if npad_lsize < npad_ssize else 'ssize'}."
+ # )
+ json_rpc_terminator = buf[p_idx-2:p_idx]
+ assert json_rpc_terminator in (b"}\n", b"]\n"), f"unexpected {json_rpc_terminator=!r}"
+ buf2 = buf[:p_idx-2] + (npad * b" ") + json_rpc_terminator
+ self._asyncio_transport.write(buf2)
+ self._last_send = time.monotonic()
+ del self._sbuffer[:p_idx]
+ if not self._sbuffer:
+ self._sbuffer_has_data_evt.clear()
+
+ async def _poll_sbuffer(self):
+ while not self.is_closing():
+ await self._can_send.wait()
+ await self._sbuffer_has_data_evt.wait() # to avoid busy-waiting
+ self._maybe_consume_sbuffer()
+ # If there is still data in the buffer, sleep until it would time out.
+ # note: If the transport is ~idle, when we wake up, we will send the current buf data,
+ # but if busy, we might wake up to completely new buffer contents. Either is fine.
+ if len(self._sbuffer) > 0:
+ timeout_abs = self._last_send + self.WAIT_FOR_BUFFER_GROWTH_SECONDS
+ timeout_rel = max(0.0, timeout_abs - time.monotonic())
+ await asyncio.sleep(timeout_rel)
+
+ def connection_made(self, transport: asyncio.BaseTransport):
+ super().connection_made(transport)
+ if isinstance(self.session, NotificationSession):
+ coro = self.session.taskgroup.spawn(self._poll_sbuffer())
+ self._sbuffer_task = self.loop.create_task(coro)
+ else:
+ # This a short-lived "fetch_certificate"-type session.
+ # No polling here, we always force-empty the buffer.
+ self._force_send = True
+
+ async def close(self, *args, **kwargs):
+ '''Close the connection and return when closed.'''
+ # Flush buffer before disconnecting. This makes ReplyAndDisconnect work:
+ self._force_send = True
+ self._maybe_consume_sbuffer()
+ await super().close(*args, **kwargs)
+
+
+class ServerAddr:
+
+ def __init__(self, host: str, port: Union[int, str], *, protocol: str = None):
+ assert isinstance(host, str), repr(host)
+ if protocol is None:
+ protocol = 's'
+ if not host:
+ raise ValueError('host must not be empty')
+ if host[0] == '[' and host[-1] == ']': # IPv6
+ host = host[1:-1]
+ try:
+ net_addr = NetAddress(host, port) # this validates host and port
+ except Exception as e:
+ raise ValueError(f"cannot construct ServerAddr: invalid host or port (host={host}, port={port})") from e
+ if protocol not in KNOWN_ELEC_PROTOCOL_TRANSPORTS:
+ raise ValueError(f"invalid network protocol: {protocol}")
+ self.host = str(net_addr.host) # canonical form (if e.g. IPv6 address)
+ self.port = int(net_addr.port)
+ self.protocol = protocol
+ self._net_addr_str = str(net_addr)
+
+ @classmethod
+ def from_str(cls, s: str) -> 'ServerAddr':
+ """Constructs a ServerAddr or raises ValueError."""
+ # host might be IPv6 address, hence do rsplit:
+ host, port, protocol = str(s).rsplit(':', 2)
+ return ServerAddr(host=host, port=port, protocol=protocol)
+
+ @classmethod
+ def from_str_with_inference(cls, s: str) -> Optional['ServerAddr']:
+ """Construct ServerAddr from str, guessing missing details.
+ Does not raise - just returns None if guessing failed.
+ Ongoing compatibility not guaranteed.
+ """
+ if not s:
+ return None
+ host = ""
+ if s[0] == "[" and "]" in s: # IPv6 address
+ host_end = s.index("]")
+ host = s[1:host_end]
+ s = s[host_end+1:]
+ items = str(s).rsplit(':', 2)
+ if len(items) < 2:
+ return None # although maybe we could guess the port too?
+ host = host or items[0]
+ port = items[1]
+ if len(items) >= 3:
+ protocol = items[2]
+ else:
+ protocol = PREFERRED_NETWORK_PROTOCOL
+ try:
+ return ServerAddr(host=host, port=port, protocol=protocol)
+ except ValueError:
+ return None
+
+ def to_friendly_name(self) -> str:
+ # note: this method is closely linked to from_str_with_inference
+ if self.protocol == 's': # hide trailing ":s"
+ return self.net_addr_str()
+ return str(self)
+
+ def __str__(self):
+ return '{}:{}'.format(self.net_addr_str(), self.protocol)
+
+ def to_json(self) -> str:
+ return str(self)
+
+ def __repr__(self):
+ return f''
+
+ def net_addr_str(self) -> str:
+ return self._net_addr_str
+
+ def __eq__(self, other):
+ if not isinstance(other, ServerAddr):
+ return False
+ return (self.host == other.host
+ and self.port == other.port
+ and self.protocol == other.protocol)
+
+ def __ne__(self, other):
+ return not (self == other)
+
+ def __hash__(self):
+ return hash((self.host, self.port, self.protocol))
+
+
+def _get_cert_path_for_host(*, config: 'SimpleConfig', host: str) -> str:
+ filename = host
+ try:
+ ip = ip_address(host)
+ except ValueError:
+ pass
+ else:
+ if isinstance(ip, IPv6Address):
+ filename = f"ipv6_{ip.packed.hex()}"
+ return os.path.join(config.path, 'certs', filename)
+
+
+class Interface(Logger):
+
+ def __init__(self, *, network: 'Network', server: ServerAddr):
+ assert isinstance(server, ServerAddr), f"expected ServerAddr, got {type(server)}"
+ self.ready = network.asyncio_loop.create_future()
+ self.got_disconnected = asyncio.Event()
+ self._blockchain_updated = asyncio.Event()
+ self.server = server
+ Logger.__init__(self)
+ assert network.config.path
+ self.cert_path = _get_cert_path_for_host(config=network.config, host=self.host)
+ self.blockchain = None # type: Optional[Blockchain]
+ self._requested_chunks = set() # type: Set[int]
+ self.network = network
+ self.session = None # type: Optional[NotificationSession]
+ self._ipaddr_bucket = None
+ # Set up proxy.
+ # - for servers running on localhost, the proxy is not used. If user runs their own server
+ # on same machine, this lets them enable the proxy (which is used for e.g. FX rates).
+ # note: we could maybe relax this further and bypass the proxy for all private
+ # addresses...? e.g. 192.168.x.x
+ if util.is_localhost(server.host):
+ self.logger.info(f"looks like localhost: not using proxy for this server")
+ self.proxy = None
+ else:
+ self.proxy = ESocksProxy.from_network_settings(network)
+
+ # Latest block header and corresponding height, as claimed by the server.
+ # Note that these values are updated before they are verified.
+ # Especially during initial header sync, verification can take a long time.
+ # Failing verification will get the interface closed.
+ self.tip_header = None # type: Optional[dict]
+ self.tip = 0
+
+ self._headers_cache = {} # type: Dict[int, bytes]
+ self._rawtx_cache = LRUCache(maxsize=20) # type: LRUCache[str, bytes] # txid->rawtx
+
+ self.fee_estimates_eta = {} # type: Dict[int, int]
+
+ self.active_protocol_tuple = (0,) # type: Optional[tuple[int, ...]]
+
+ # Dump network messages (only for this interface). Set at runtime from the console.
+ self.debug = False
+
+ self.taskgroup = OldTaskGroup()
+
+ async def spawn_task():
+ task = await self.network.taskgroup.spawn(self.run())
+ task.set_name(f"interface::{str(server)}")
+ asyncio.run_coroutine_threadsafe(spawn_task(), self.network.asyncio_loop)
+
+ @property
+ def host(self):
+ return self.server.host
+
+ @property
+ def port(self):
+ return self.server.port
+
+ @property
+ def protocol(self):
+ return self.server.protocol
+
+ def diagnostic_name(self):
+ return self.server.net_addr_str()
+
+ def __str__(self):
+ return f""
+
+ async def is_server_ca_signed(self, ca_ssl_context: ssl.SSLContext) -> bool:
+ """Given a CA enforcing SSL context, returns True if the connection
+ can be established. Returns False if the server has a self-signed
+ certificate but otherwise is okay. Any other failures raise.
+ """
+ try:
+ await self.open_session(ssl_context=ca_ssl_context, exit_early=True)
+ except ConnectError as e:
+ cause = e.__cause__
+ if (isinstance(cause, ssl.SSLCertVerificationError)
+ and cause.reason == 'CERTIFICATE_VERIFY_FAILED'
+ and cause.verify_code == 18): # "self signed certificate"
+ # Good. We will use this server as self-signed.
+ return False
+ # Not good. Cannot use this server.
+ raise
+ # Good. We will use this server as CA-signed.
+ return True
+
+ async def _try_saving_ssl_cert_for_first_time(self, ca_ssl_context: ssl.SSLContext) -> None:
+ ca_signed = await self.is_server_ca_signed(ca_ssl_context)
+ if ca_signed:
+ if self._get_expected_fingerprint():
+ raise InvalidOptionCombination("cannot use --serverfingerprint with CA signed servers")
+ with open(self.cert_path, 'w') as f:
+ # empty file means this is CA signed, not self-signed
+ f.write('')
+ else:
+ await self._save_certificate()
+
+ def _is_saved_ssl_cert_available(self):
+ if not os.path.exists(self.cert_path):
+ return False
+ with open(self.cert_path, 'r') as f:
+ contents = f.read()
+ if contents == '': # CA signed
+ if self._get_expected_fingerprint():
+ raise InvalidOptionCombination("cannot use --serverfingerprint with CA signed servers")
+ return True
+ # pinned self-signed cert
+ try:
+ b = pem.dePem(contents, 'CERTIFICATE')
+ except SyntaxError as e:
+ self.logger.info(f"error parsing already saved cert: {e}")
+ raise ErrorParsingSSLCert(e) from e
+ try:
+ x = x509.X509(b)
+ except Exception as e:
+ self.logger.info(f"error parsing already saved cert: {e}")
+ raise ErrorParsingSSLCert(e) from e
+ try:
+ x.check_date()
+ except x509.CertificateError as e:
+ self.logger.info(f"certificate has expired: {e}")
+ os.unlink(self.cert_path) # delete pinned cert only in this case
+ return False
+ self._verify_certificate_fingerprint(bytes(b))
+ return True
+
+ async def _get_ssl_context(self) -> Optional[ssl.SSLContext]:
+ if self.protocol != 's':
+ # using plaintext TCP
+ return None
+
+ # see if we already have cert for this server; or get it for the first time
+ ca_sslc = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH, cafile=ca_path)
+ if not self._is_saved_ssl_cert_available():
+ try:
+ await self._try_saving_ssl_cert_for_first_time(ca_sslc)
+ except (OSError, ConnectError, aiorpcx.socks.SOCKSError) as e:
+ raise ErrorGettingSSLCertFromServer(e) from e
+ # now we have a file saved in our certificate store
+ siz = os.stat(self.cert_path).st_size
+ if siz == 0:
+ # CA signed cert
+ sslc = ca_sslc
+ else:
+ # pinned self-signed cert
+ sslc = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH, cafile=self.cert_path)
+ # note: Flag "ssl.VERIFY_X509_STRICT" is enabled by default in python 3.13+ (disabled in older versions).
+ # We explicitly disable it as it breaks lots of servers.
+ sslc.verify_flags &= ~ssl.VERIFY_X509_STRICT
+ sslc.check_hostname = False
+ return sslc
+
+ def handle_disconnect(func):
+ @functools.wraps(func)
+ async def wrapper_func(self: 'Interface', *args, **kwargs):
+ try:
+ return await func(self, *args, **kwargs)
+ except GracefulDisconnect as e:
+ self.logger.log(e.log_level, f"disconnecting due to {repr(e)}")
+ except aiorpcx.jsonrpc.RPCError as e:
+ self.logger.warning(f"disconnecting due to {repr(e)}")
+ self.logger.debug(f"(disconnect) trace for {repr(e)}", exc_info=True)
+ finally:
+ self.got_disconnected.set()
+ # Make sure taskgroup gets cleaned-up. This explicit clean-up is needed here
+ # in case the "with taskgroup" ctx mgr never got a chance to run:
+ await self.taskgroup.cancel_remaining()
+ await self.network.connection_down(self)
+ # if was not 'ready' yet, schedule waiting coroutines:
+ self.ready.cancel()
+ return wrapper_func
+
+ @ignore_exceptions # do not kill network.taskgroup
+ @log_exceptions
+ @handle_disconnect
+ async def run(self):
+ try:
+ ssl_context = await self._get_ssl_context()
+ except (ErrorParsingSSLCert, ErrorGettingSSLCertFromServer) as e:
+ self.logger.info(f'disconnecting due to: {repr(e)}')
+ return
+ try:
+ await self.open_session(ssl_context=ssl_context)
+ except (asyncio.CancelledError, ConnectError, aiorpcx.socks.SOCKSError) as e:
+ # make SSL errors for main interface more visible (to help servers ops debug cert pinning issues)
+ if (isinstance(e, ConnectError) and isinstance(e.__cause__, ssl.SSLError)
+ and self.is_main_server() and not self.network.auto_connect):
+ self.logger.warning(f'Cannot connect to main server due to SSL error '
+ f'(maybe cert changed compared to "{self.cert_path}"). Exc: {repr(e)}')
+ else:
+ self.logger.info(f'disconnecting due to: {repr(e)}')
+ return
+
+ def _mark_ready(self) -> None:
+ if self.ready.cancelled():
+ raise GracefulDisconnect('conn establishment was too slow; *ready* future was cancelled')
+ if self.ready.done():
+ return
+
+ assert self.tip_header
+ chain = blockchain.check_header(self.tip_header)
+ if not chain:
+ self.blockchain = blockchain.get_best_chain()
+ else:
+ self.blockchain = chain
+ assert self.blockchain is not None
+
+ self.logger.info(f"set blockchain with height {self.blockchain.height()}")
+
+ self.ready.set_result(1)
+
+ def is_connected_and_ready(self) -> bool:
+ return self.ready.done() and not self.got_disconnected.is_set()
+
+ async def _save_certificate(self) -> None:
+ if not os.path.exists(self.cert_path):
+ # we may need to retry this a few times, in case the handshake hasn't completed
+ for _ in range(10):
+ dercert = await self._fetch_certificate()
+ if dercert:
+ self.logger.info("succeeded in getting cert")
+ self._verify_certificate_fingerprint(dercert)
+ with open(self.cert_path, 'w') as f:
+ cert = ssl.DER_cert_to_PEM_cert(dercert)
+ # workaround android bug
+ cert = re.sub("([^\n])-----END CERTIFICATE-----","\\1\n-----END CERTIFICATE-----",cert)
+ f.write(cert)
+ # even though close flushes, we can't fsync when closed.
+ # and we must flush before fsyncing, cause flush flushes to OS buffer
+ # fsync writes to OS buffer to disk
+ f.flush()
+ os.fsync(f.fileno())
+ break
+ await asyncio.sleep(1)
+ else:
+ raise GracefulDisconnect("could not get certificate after 10 tries")
+
+ async def _fetch_certificate(self) -> bytes:
+ sslc = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS_CLIENT)
+ sslc.check_hostname = False
+ sslc.verify_mode = ssl.CERT_NONE
+ async with _RSClient(
+ session_factory=RPCSession,
+ host=self.host, port=self.port,
+ ssl=sslc,
+ proxy=self.proxy,
+ transport=PaddedRSTransport,
+ ) as session:
+ asyncio_transport = session.transport._asyncio_transport # type: asyncio.BaseTransport
+ ssl_object = asyncio_transport.get_extra_info("ssl_object") # type: ssl.SSLObject
+ return ssl_object.getpeercert(binary_form=True)
+
+ def _get_expected_fingerprint(self) -> Optional[str]:
+ if self.is_main_server():
+ return self.network.config.NETWORK_SERVERFINGERPRINT
+ return None
+
+ def _verify_certificate_fingerprint(self, certificate: bytes) -> None:
+ expected_fingerprint = self._get_expected_fingerprint()
+ if not expected_fingerprint:
+ return
+ fingerprint = hashlib.sha256(certificate).hexdigest()
+ fingerprints_match = fingerprint.lower() == expected_fingerprint.lower()
+ if not fingerprints_match:
+ util.trigger_callback('cert_mismatch')
+ raise ErrorSSLCertFingerprintMismatch('Refusing to connect to server due to cert fingerprint mismatch')
+ self.logger.info("cert fingerprint verification passed")
+
+ async def _maybe_warm_headers_cache(self, *, from_height: int, to_height: int, mode: ChainResolutionMode) -> None:
+ """Populate header cache for block heights in range [from_height, to_height]."""
+ assert from_height <= to_height, (from_height, to_height)
+ assert to_height - from_height < MAX_NUM_HEADERS_PER_REQUEST
+ if all(height in self._headers_cache for height in range(from_height, to_height+1)):
+ # cache already has all requested headers
+ return
+ # use lower timeout as we usually have network.bhi_lock here
+ timeout = self.network.get_network_timeout_seconds(NetworkTimeout.Urgent)
+ count = to_height - from_height + 1
+ headers = await self.get_block_headers(start_height=from_height, count=count, timeout=timeout, mode=mode)
+ for idx, raw_header in enumerate(headers):
+ header_height = from_height + idx
+ self._headers_cache[header_height] = raw_header
+
+ async def get_block_header(self, height: int, *, mode: ChainResolutionMode) -> dict:
+ if not is_non_negative_integer(height):
+ raise Exception(f"{repr(height)} is not a block height")
+ #self.logger.debug(f'get_block_header() {height} in {mode=}')
+ # use lower timeout as we usually have network.bhi_lock here
+ timeout = self.network.get_network_timeout_seconds(NetworkTimeout.Urgent)
+ if raw_header := self._headers_cache.get(height):
+ return blockchain.deserialize_header(raw_header, height)
+ self.logger.info(f'requesting block header {height} in {mode=}')
+ res = await self.session.send_request('blockchain.block.header', [height], timeout=timeout)
+ return blockchain.deserialize_header(bytes.fromhex(res), height)
+
+ async def get_block_headers(
+ self,
+ *,
+ start_height: int,
+ count: int,
+ timeout=None,
+ mode: Optional[ChainResolutionMode] = None,
+ ) -> Sequence[bytes]:
+ """Request a number of consecutive block headers, starting at `start_height`.
+ `count` is the num of requested headers, BUT note the server might return fewer than this
+ (if range would extend beyond its tip).
+ note: the returned headers are not verified or parsed at all.
+ """
+ if not is_non_negative_integer(start_height):
+ raise Exception(f"{repr(start_height)} is not a block height")
+ if not is_non_negative_integer(count) or not (0 < count <= MAX_NUM_HEADERS_PER_REQUEST):
+ raise Exception(f"{repr(count)} not an int in range ]0, {MAX_NUM_HEADERS_PER_REQUEST}]")
+ self.logger.info(
+ f"requesting block headers: [{start_height}, {start_height+count-1}], {count=}"
+ + (f" (in {mode=})" if mode is not None else "")
+ )
+ res = await self.session.send_request('blockchain.block.headers', [start_height, count], timeout=timeout)
+ # check response
+ assert_dict_contains_field(res, field_name='count')
+ assert_dict_contains_field(res, field_name='max')
+ assert_non_negative_integer(res['count'])
+ assert_non_negative_integer(res['max'])
+ if self.active_protocol_tuple >= (1, 6):
+ hex_headers_list = assert_dict_contains_field(res, field_name='headers')
+ assert_list_or_tuple(hex_headers_list)
+ for item in hex_headers_list:
+ assert_hex_str(item)
+ if len(item) != HEADER_SIZE * 2:
+ raise RequestCorrupted(f"invalid header size. got {len(item)//2}, expected {HEADER_SIZE}")
+ if len(hex_headers_list) != res['count']:
+ raise RequestCorrupted(f"{len(hex_headers_list)=} != {res['count']=}")
+ headers = list(bfh(hex_header) for hex_header in hex_headers_list)
+ else: # proto 1.4
+ hex_headers_concat = assert_dict_contains_field(res, field_name='hex')
+ assert_hex_str(hex_headers_concat)
+ if len(hex_headers_concat) != HEADER_SIZE * 2 * res['count']:
+ raise RequestCorrupted('inconsistent chunk hex and count')
+ headers = list(util.chunks(bfh(hex_headers_concat), size=HEADER_SIZE))
+ # we never request more than MAX_NUM_HEADERS_IN_REQUEST headers, but we enforce those fit in a single response
+ if res['max'] < MAX_NUM_HEADERS_PER_REQUEST:
+ raise RequestCorrupted(f"server uses too low 'max' count for block.headers: {res['max']} < {MAX_NUM_HEADERS_PER_REQUEST}")
+ if res['count'] > count:
+ raise RequestCorrupted(f"asked for {count} headers but got more: {res['count']}")
+ elif res['count'] < count:
+ # we only tolerate getting fewer headers if it is due to reaching the tip
+ end_height = start_height + res['count'] - 1
+ if end_height < self.tip: # still below tip. why did server not send more?!
+ raise RequestCorrupted(
+ f"asked for {count} headers but got fewer: {res['count']}. ({start_height=}, {self.tip=})")
+ # checks done.
+ return headers
+
+ async def request_chunk_below_max_checkpoint(
+ self,
+ *,
+ height: int,
+ ) -> None:
+ if not is_non_negative_integer(height):
+ raise Exception(f"{repr(height)} is not a block height")
+ assert height <= constants.net.max_checkpoint(), f"{height=} must be <= cp={constants.net.max_checkpoint()}"
+ index = height // CHUNK_SIZE
+ if index in self._requested_chunks:
+ return None
+ self.logger.debug(f"requesting chunk from height {height}")
+ try:
+ self._requested_chunks.add(index)
+ headers = await self.get_block_headers(start_height=index * CHUNK_SIZE, count=CHUNK_SIZE)
+ finally:
+ self._requested_chunks.discard(index)
+ conn = self.blockchain.connect_chunk(index, data=b"".join(headers))
+ if not conn:
+ raise RequestCorrupted(f"chunk ({index=}, for {height=}) does not connect to blockchain")
+ return None
+
+ async def _fast_forward_chain(
+ self,
+ *,
+ height: int, # usually local chain tip + 1
+ tip: int, # server tip. we should not request past this.
+ ) -> int:
+ """Request some headers starting at `height` to grow the blockchain of this interface.
+ Returns number of headers we managed to connect, starting at `height`.
+ """
+ if not is_non_negative_integer(height):
+ raise Exception(f"{repr(height)} is not a block height")
+ if not is_non_negative_integer(tip):
+ raise Exception(f"{repr(tip)} is not a block height")
+ if not (height > constants.net.max_checkpoint()
+ or height == 0 == constants.net.max_checkpoint()):
+ raise Exception(f"{height=} must be > cp={constants.net.max_checkpoint()}")
+ assert height <= tip, f"{height=} must be <= {tip=}"
+ # Request a few chunks of headers concurrently.
+ # tradeoffs:
+ # - more chunks: higher memory requirements
+ # - more chunks: higher concurrency => syncing needs fewer network round-trips
+ # - if a chunk does not connect, bandwidth for all later chunks is wasted
+ async with OldTaskGroup() as group:
+ tasks = [] # type: List[Tuple[int, asyncio.Task[Sequence[bytes]]]]
+ index0 = height // CHUNK_SIZE
+ for chunk_cnt in range(10):
+ index = index0 + chunk_cnt
+ start_height = index * CHUNK_SIZE
+ if start_height > tip:
+ break
+ end_height = min(start_height + CHUNK_SIZE - 1, tip)
+ size = end_height - start_height + 1
+ tasks.append((index, await group.spawn(self.get_block_headers(start_height=start_height, count=size))))
+ # try to connect chunks
+ num_headers = 0
+ for index, task in tasks:
+ headers = task.result()
+ conn = self.blockchain.connect_chunk(index, data=b"".join(headers))
+ if not conn:
+ break
+ num_headers += len(headers)
+ # We started at a chunk boundary, instead of requested `height`. Need to correct for that.
+ offset = height - index0 * CHUNK_SIZE
+ return max(0, num_headers - offset)
+
+ def is_main_server(self) -> bool:
+ return (self.network.interface == self or
+ self.network.interface is None and self.network.default_server == self.server)
+
+ async def open_session(
+ self,
+ *,
+ ssl_context: Optional[ssl.SSLContext],
+ exit_early: bool = False,
+ ):
+ session_factory = lambda *args, iface=self, **kwargs: NotificationSession(*args, **kwargs, interface=iface)
+ async with _RSClient(
+ session_factory=session_factory,
+ host=self.host, port=self.port,
+ ssl=ssl_context,
+ proxy=self.proxy,
+ transport=PaddedRSTransport,
+ ) as session:
+ start = time.perf_counter()
+ self.session = session # type: NotificationSession
+ self.session.set_default_timeout(self.network.get_network_timeout_seconds(NetworkTimeout.Generic))
+ client_prange = [version.PROTOCOL_VERSION_MIN, version.PROTOCOL_VERSION_MAX]
+ try:
+ ver = await session.send_request('server.version', [self.client_name(), client_prange])
+ except aiorpcx.jsonrpc.RPCError as e:
+ raise GracefulDisconnect(e) # probably 'unsupported protocol version'
+ if exit_early:
+ return
+ self.active_protocol_tuple = protocol_tuple(ver[1])
+ client_pmin = protocol_tuple(client_prange[0])
+ client_pmax = protocol_tuple(client_prange[1])
+ if not (client_pmin <= self.active_protocol_tuple <= client_pmax):
+ raise GracefulDisconnect(f'server violated protocol-version-negotiation. '
+ f'we asked for {client_prange!r}, they sent {ver[1]!r}')
+ if not self.network.check_interface_against_healthy_spread_of_connected_servers(self):
+ raise GracefulDisconnect(f'too many connected servers already '
+ f'in bucket {self.bucket_based_on_ipaddress()}')
+
+ try:
+ features = await session.send_request('server.features')
+ server_genesis_hash = assert_dict_contains_field(features, field_name='genesis_hash')
+ except (aiorpcx.jsonrpc.RPCError, RequestCorrupted) as e:
+ raise GracefulDisconnect(e)
+ if server_genesis_hash != constants.net.GENESIS:
+ raise GracefulDisconnect(f'server on different chain: {server_genesis_hash=}. ours: {constants.net.GENESIS}')
+ self.logger.info(f"connection established. version: {ver}, handshake duration: {(time.perf_counter() - start) * 1000:.2f} ms")
+
+ try:
+ async with self.taskgroup as group:
+ await group.spawn(self.ping)
+ await group.spawn(self.request_fee_estimates)
+ await group.spawn(self.run_fetch_blocks)
+ await group.spawn(self.monitor_connection)
+ except aiorpcx.jsonrpc.RPCError as e:
+ if e.code in (
+ JSONRPC.EXCESSIVE_RESOURCE_USAGE,
+ JSONRPC.SERVER_BUSY,
+ JSONRPC.METHOD_NOT_FOUND,
+ JSONRPC.INTERNAL_ERROR,
+ ):
+ log_level = logging.WARNING if self.is_main_server() else logging.INFO
+ raise GracefulDisconnect(e, log_level=log_level) from e
+ raise
+ finally:
+ self.got_disconnected.set() # set this ASAP, ideally before any awaits
+
+ async def monitor_connection(self):
+ while True:
+ await asyncio.sleep(1)
+ # If the session/transport is no longer open, we disconnect.
+ # e.g. if the remote cleanly sends EOF, we would handle that here.
+ # note: If the user pulls the ethernet cable or disconnects wifi,
+ # ideally we would detect that here, so that the GUI/etc can reflect that.
+ # - On Android, this seems to work reliably , where asyncio.BaseProtocol.connection_lost()
+ # gets called with e.g. ConnectionAbortedError(103, 'Software caused connection abort').
+ # - On desktop Linux/Win, it seems BaseProtocol.connection_lost() is not called in such cases.
+ # Hence, in practice the connection issue will only be detected the next time we try
+ # to send a message (plus timeout), which can take minutes...
+ if not self.session or self.session.is_closing():
+ raise GracefulDisconnect('session was closed')
+
+ async def ping(self):
+ # We periodically send a "ping" msg to make sure the server knows we are still here.
+ # Adding a bit of randomness generates some noise against traffic analysis.
+ while True:
+ await asyncio.sleep(random.random() * 300)
+ await self.session.send_request('server.ping')
+ await self._maybe_send_noise()
+
+ async def _maybe_send_noise(self):
+ while random.random() < 0.2:
+ await asyncio.sleep(random.random())
+ await self.session.send_request('server.ping')
+
+ async def request_fee_estimates(self):
+ while True:
+ async with OldTaskGroup() as group:
+ fee_tasks = []
+ for i in FEE_ETA_TARGETS[0:-1]:
+ fee_tasks.append((i, await group.spawn(self.get_estimatefee(i))))
+ for nblock_target, task in fee_tasks:
+ fee = task.result()
+ if fee < 0: continue
+ assert isinstance(fee, int)
+ self.fee_estimates_eta[nblock_target] = fee
+ self.network.update_fee_estimates()
+ await asyncio.sleep(60)
+
+ async def close(self, *, force_after: int = None):
+ """Closes the connection and waits for it to be closed.
+ We try to flush buffered data to the wire, which can take some time.
+ """
+ if self.session:
+ await self.session.close(force_after=force_after)
+ # monitor_connection will cancel tasks
+
+ async def run_fetch_blocks(self):
+ header_queue = asyncio.Queue()
+ await self.session.subscribe('blockchain.headers.subscribe', [], header_queue)
+ while True:
+ item = await header_queue.get()
+ raw_header = item[0]
+ height = raw_header['height']
+ header_bytes = bfh(raw_header['hex'])
+ header_dict = blockchain.deserialize_header(header_bytes, height)
+ self.tip_header = header_dict
+ self.tip = height
+ if self.tip < constants.net.max_checkpoint():
+ raise GracefulDisconnect(
+ f"server tip below max checkpoint. ({self.tip} < {constants.net.max_checkpoint()})")
+ self._mark_ready()
+ self._headers_cache.clear() # tip changed, so assume anything could have happened with chain
+ self._headers_cache[height] = header_bytes
+ try:
+ blockchain_updated = await self._process_header_at_tip()
+ finally:
+ self._headers_cache.clear() # to reduce memory usage
+ # header processing done
+ if self.is_main_server() or blockchain_updated:
+ self.logger.info(f"new chain tip. {height=}")
+ if blockchain_updated:
+ util.trigger_callback('blockchain_updated')
+ self._blockchain_updated.set()
+ self._blockchain_updated.clear()
+ util.trigger_callback('network_updated')
+ await self.network.switch_unwanted_fork_interface()
+ await self.network.switch_lagging_interface()
+ await self.taskgroup.spawn(self._maybe_send_noise())
+
+ async def _process_header_at_tip(self) -> bool:
+ """Returns:
+ False - boring fast-forward: we already have this header as part of this blockchain from another interface,
+ True - new header we didn't have, or reorg
+ """
+ height, header = self.tip, self.tip_header
+ async with self.network.bhi_lock:
+ if self.blockchain.height() >= height and self.blockchain.check_header(header):
+ # another interface amended the blockchain
+ return False
+ await self.sync_until(height)
+ return True
+
+ async def sync_until(
+ self,
+ height: int,
+ *,
+ next_height: Optional[int] = None, # sync target. typically the tip, except in unit tests
+ ) -> Tuple[ChainResolutionMode, int]:
+ if next_height is None:
+ next_height = self.tip
+ last = None # type: Optional[ChainResolutionMode]
+ while last is None or height <= next_height:
+ prev_last, prev_height = last, height
+ if next_height > height + 144:
+ # We are far from the tip.
+ # It is more efficient to process headers in large batches (CPU/disk_usage/logging).
+ # (but this wastes a little bandwidth, if we are not on a chunk boundary)
+ num_headers = await self._fast_forward_chain(
+ height=height, tip=next_height)
+ if num_headers == 0:
+ if height <= constants.net.max_checkpoint():
+ raise GracefulDisconnect('server chain conflicts with checkpoints or genesis')
+ last, height = await self.step(height)
+ continue
+ # report progress to gui/etc
+ util.trigger_callback('blockchain_updated')
+ self._blockchain_updated.set()
+ self._blockchain_updated.clear()
+ util.trigger_callback('network_updated')
+ height += num_headers
+ assert height <= next_height+1, (height, self.tip)
+ last = ChainResolutionMode.CATCHUP
+ else:
+ # We are close to the tip, so process headers one-by-one.
+ # (note: due to headers_cache, to save network latency, this can still batch-request headers)
+ last, height = await self.step(height)
+ assert (prev_last, prev_height) != (last, height), 'had to prevent infinite loop in interface.sync_until'
+ return last, height
+
+ async def step(
+ self,
+ height: int,
+ ) -> Tuple[ChainResolutionMode, int]:
+ assert 0 <= height <= self.tip, (height, self.tip)
+ await self._maybe_warm_headers_cache(
+ from_height=height,
+ to_height=min(self.tip, height+MAX_NUM_HEADERS_PER_REQUEST-1),
+ mode=ChainResolutionMode.CATCHUP,
+ )
+ header = await self.get_block_header(height, mode=ChainResolutionMode.CATCHUP)
+
+ chain = blockchain.check_header(header)
+ if chain:
+ self.blockchain = chain
+ # note: there is an edge case here that is not handled.
+ # we might know the blockhash (enough for check_header) but
+ # not have the header itself. e.g. regtest chain with only genesis.
+ # this situation resolves itself on the next block
+ return ChainResolutionMode.CATCHUP, height+1
+
+ can_connect = blockchain.can_connect(header)
+ if not can_connect:
+ self.logger.info(f"can't connect new block: {height=}")
+ height, header, bad, bad_header = await self._search_headers_backwards(height, header=header)
+ chain = blockchain.check_header(header)
+ can_connect = blockchain.can_connect(header)
+ assert chain or can_connect
+ if can_connect:
+ height += 1
+ self.blockchain = can_connect
+ self.blockchain.save_header(header)
+ return ChainResolutionMode.CATCHUP, height
+
+ good, bad, bad_header = await self._search_headers_binary(height, bad, bad_header, chain)
+ return await self._resolve_potential_chain_fork_given_forkpoint(good, bad, bad_header)
+
+ async def _search_headers_binary(
+ self,
+ height: int,
+ bad: int,
+ bad_header: dict,
+ chain: Optional[Blockchain],
+ ) -> Tuple[int, int, dict]:
+ assert bad == bad_header['block_height']
+ _assert_header_does_not_check_against_any_chain(bad_header)
+
+ self.blockchain = chain
+ good = height
+ while True:
+ assert 0 <= good < bad, (good, bad)
+ height = (good + bad) // 2
+ self.logger.info(f"binary step. good {good}, bad {bad}, height {height}")
+ if bad - good + 1 <= MAX_NUM_HEADERS_PER_REQUEST: # if interval is small, trade some bandwidth for lower latency
+ await self._maybe_warm_headers_cache(
+ from_height=good, to_height=bad, mode=ChainResolutionMode.BINARY)
+ header = await self.get_block_header(height, mode=ChainResolutionMode.BINARY)
+ chain = blockchain.check_header(header)
+ if chain:
+ self.blockchain = chain
+ good = height
+ else:
+ bad = height
+ bad_header = header
+ if good + 1 == bad:
+ break
+
+ if not self.blockchain.can_connect(bad_header, check_height=False):
+ raise Exception('unexpected bad header during binary: {}'.format(bad_header))
+ _assert_header_does_not_check_against_any_chain(bad_header)
+
+ self.logger.info(f"binary search exited. good {good}, bad {bad}. {chain=}")
+ return good, bad, bad_header
+
+ async def _resolve_potential_chain_fork_given_forkpoint(
+ self,
+ good: int,
+ bad: int,
+ bad_header: dict,
+ ) -> Tuple[ChainResolutionMode, int]:
+ assert good + 1 == bad
+ assert bad == bad_header['block_height']
+ _assert_header_does_not_check_against_any_chain(bad_header)
+ # 'good' is the height of a block 'good_header', somewhere in self.blockchain.
+ # bad_header connects to good_header; bad_header itself is NOT in self.blockchain.
+
+ bh = self.blockchain.height()
+ assert bh >= good, (bh, good)
+ if bh == good:
+ height = good + 1
+ self.logger.info(f"catching up from {height}")
+ return ChainResolutionMode.NO_FORK, height
+
+ # this is a new fork we don't yet have
+ height = bad + 1
+ self.logger.info(f"new fork at bad height {bad}")
+ b = self.blockchain.fork(bad_header) # type: Blockchain
+ self.blockchain = b
+ assert b.forkpoint == bad
+ return ChainResolutionMode.FORK, height
+
+ async def _search_headers_backwards(
+ self,
+ height: int,
+ *,
+ header: dict,
+ ) -> Tuple[int, dict, int, dict]:
+ async def iterate():
+ nonlocal height, header
+ checkp = False
+ if height <= constants.net.max_checkpoint():
+ height = constants.net.max_checkpoint()
+ checkp = True
+ header = await self.get_block_header(height, mode=ChainResolutionMode.BACKWARD)
+ chain = blockchain.check_header(header)
+ can_connect = blockchain.can_connect(header)
+ if chain or can_connect:
+ return False
+ if checkp:
+ raise GracefulDisconnect("server chain conflicts with checkpoints")
+ return True
+
+ bad, bad_header = height, header
+ _assert_header_does_not_check_against_any_chain(bad_header)
+ with blockchain.blockchains_lock: chains = list(blockchain.blockchains.values())
+ local_max = max([0] + [x.height() for x in chains])
+ height = min(local_max + 1, height - 1)
+ assert height >= 0
+
+ await self._maybe_warm_headers_cache(
+ from_height=max(0, height-10), to_height=height, mode=ChainResolutionMode.BACKWARD)
+
+ delta = 2
+ while await iterate():
+ bad, bad_header = height, header
+ height -= delta
+ delta *= 2
+
+ _assert_header_does_not_check_against_any_chain(bad_header)
+ self.logger.info(f"exiting backward mode at {height}")
+ return height, header, bad, bad_header
+
+ @classmethod
+ def client_name(cls) -> str:
+ return f'electrum/{version.ELECTRUM_VERSION}'
+
+ def is_tor(self):
+ return self.host.endswith('.onion')
+
+ def ip_addr(self) -> Optional[str]:
+ session = self.session
+ if not session: return None
+ peer_addr = session.remote_address()
+ if not peer_addr: return None
+ return str(peer_addr.host)
+
+ def bucket_based_on_ipaddress(self) -> str:
+ def do_bucket():
+ if self.is_tor():
+ return BUCKET_NAME_OF_ONION_SERVERS
+ try:
+ ip_addr = ip_address(self.ip_addr()) # type: Union[IPv4Address, IPv6Address]
+ except ValueError:
+ return ''
+ if not ip_addr:
+ return ''
+ if ip_addr.is_loopback: # localhost is exempt
+ return ''
+ if ip_addr.version == 4:
+ slash16 = IPv4Network(ip_addr).supernet(prefixlen_diff=32-16)
+ return str(slash16)
+ elif ip_addr.version == 6:
+ slash48 = IPv6Network(ip_addr).supernet(prefixlen_diff=128-48)
+ return str(slash48)
+ return ''
+
+ if not self._ipaddr_bucket:
+ self._ipaddr_bucket = do_bucket()
+ return self._ipaddr_bucket
+
+ async def get_merkle_for_transaction(self, tx_hash: str, tx_height: int) -> dict:
+ if not is_hash256_str(tx_hash):
+ raise Exception(f"{repr(tx_hash)} is not a txid")
+ if not is_non_negative_integer(tx_height):
+ raise Exception(f"{repr(tx_height)} is not a block height")
+ # do request
+ res = await self.session.send_request('blockchain.transaction.get_merkle', [tx_hash, tx_height])
+ # check response
+ block_height = assert_dict_contains_field(res, field_name='block_height')
+ merkle = assert_dict_contains_field(res, field_name='merkle')
+ pos = assert_dict_contains_field(res, field_name='pos')
+ # note: tx_height was just a hint to the server, don't enforce the response to match it
+ assert_non_negative_integer(block_height)
+ assert_non_negative_integer(pos)
+ assert_list_or_tuple(merkle)
+ for item in merkle:
+ assert_hash256_str(item)
+ return res
+
+ async def get_transaction(self, tx_hash: str, *, timeout=None) -> str:
+ if not is_hash256_str(tx_hash):
+ raise Exception(f"{repr(tx_hash)} is not a txid")
+ if rawtx_bytes := self._rawtx_cache.get(tx_hash):
+ return rawtx_bytes.hex()
+ raw = await self.session.send_request('blockchain.transaction.get', [tx_hash], timeout=timeout)
+ # validate response
+ if not is_hex_str(raw):
+ raise RequestCorrupted(f"received garbage (non-hex) as tx data (txid {tx_hash}): {raw!r}")
+ tx = Transaction(raw)
+ try:
+ tx.deserialize() # see if raises
+ except Exception as e:
+ raise RequestCorrupted(f"cannot deserialize received transaction (txid {tx_hash})") from e
+ if tx.txid() != tx_hash:
+ raise RequestCorrupted(f"received tx does not match expected txid {tx_hash} (got {tx.txid()})")
+ self._rawtx_cache[tx_hash] = bytes.fromhex(raw)
+ return raw
+
+ async def broadcast_transaction(self, tx: 'Transaction', *, timeout=None) -> None:
+ """caller should handle TxBroadcastError and RequestTimedOut"""
+ txid_calc = tx.txid()
+ assert txid_calc is not None
+ rawtx = tx.serialize()
+ assert is_hex_str(rawtx)
+ if timeout is None:
+ timeout = self.network.get_network_timeout_seconds(NetworkTimeout.Urgent)
+ if any(DummyAddress.is_dummy_address(txout.address) for txout in tx.outputs()):
+ raise DummyAddressUsedInTxException("tried to broadcast tx with dummy address!")
+ try:
+ out = await self.session.send_request('blockchain.transaction.broadcast', [rawtx], timeout=timeout)
+ # note: both 'out' and exception messages are untrusted input from the server
+ except (RequestTimedOut, asyncio.CancelledError, asyncio.TimeoutError):
+ raise # pass-through
+ except aiorpcx.jsonrpc.CodeMessageError as e:
+ self.logger.info(f"broadcast_transaction error [DO NOT TRUST THIS MESSAGE]: {error_text_str_to_safe_str(repr(e))}. tx={str(tx)}")
+ raise TxBroadcastServerReturnedError(sanitize_tx_broadcast_response(e.message)) from e
+ except BaseException as e: # intentional BaseException for sanity!
+ self.logger.info(f"broadcast_transaction error2 [DO NOT TRUST THIS MESSAGE]: {error_text_str_to_safe_str(repr(e))}. tx={str(tx)}")
+ send_exception_to_crash_reporter(e)
+ raise TxBroadcastUnknownError() from e
+ if out != txid_calc:
+ self.logger.info(f"unexpected txid for broadcast_transaction [DO NOT TRUST THIS MESSAGE]: "
+ f"{error_text_str_to_safe_str(out)} != {txid_calc}. tx={str(tx)}")
+ raise TxBroadcastHashMismatch(_("Server returned unexpected transaction ID."))
+ # broadcast succeeded.
+ # We now cache the rawtx, for *this interface only*. The tx likely touches some ismine addresses, affecting
+ # the status of a scripthash we are subscribed to. Caching here will save a future get_transaction RPC.
+ self._rawtx_cache[txid_calc] = bytes.fromhex(rawtx)
+
+ async def broadcast_txpackage(self, txs: Sequence['Transaction']) -> bool:
+ assert self.active_protocol_tuple >= (1, 6), f"server using old protocol: {self.active_protocol_tuple}"
+ rawtxs = [tx.serialize() for tx in txs]
+ assert all(is_hex_str(rawtx) for rawtx in rawtxs)
+ assert all(tx.txid() is not None for tx in txs)
+ timeout = self.network.get_network_timeout_seconds(NetworkTimeout.Urgent)
+ for tx in txs:
+ if any(DummyAddress.is_dummy_address(txout.address) for txout in tx.outputs()):
+ raise DummyAddressUsedInTxException("tried to broadcast tx with dummy address!")
+ try:
+ res = await self.session.send_request('blockchain.transaction.broadcast_package', [rawtxs], timeout=timeout)
+ except aiorpcx.jsonrpc.CodeMessageError as e:
+ self.logger.info(f"broadcast_txpackage error [DO NOT TRUST THIS MESSAGE]: {error_text_str_to_safe_str(repr(e))}. {rawtxs=}")
+ return False
+ success = assert_dict_contains_field(res, field_name='success')
+ if not success:
+ errors = assert_dict_contains_field(res, field_name='errors')
+ self.logger.info(f"broadcast_txpackage error [DO NOT TRUST THIS MESSAGE]: {error_text_str_to_safe_str(repr(errors))}. {rawtxs=}")
+ return False
+ assert success
+ # broadcast succeeded.
+ # We now cache the rawtx, for *this interface only*. The tx likely touches some ismine addresses, affecting
+ # the status of a scripthash we are subscribed to. Caching here will save a future get_transaction RPC.
+ for tx, rawtx in zip(txs, rawtxs):
+ self._rawtx_cache[tx.txid()] = bytes.fromhex(rawtx)
+ return True
+
+ async def get_history_for_scripthash(self, sh: str) -> List[dict]:
+ if not is_hash256_str(sh):
+ raise Exception(f"{repr(sh)} is not a scripthash")
+ # do request
+ res = await self.session.send_request('blockchain.scripthash.get_history', [sh])
+ # check response
+ assert_list_or_tuple(res)
+ prev_height = 1
+ for tx_item in res:
+ height = assert_dict_contains_field(tx_item, field_name='height')
+ assert_dict_contains_field(tx_item, field_name='tx_hash')
+ assert_integer(height)
+ if height < -1:
+ raise RequestCorrupted(f'{height!r} is not a valid block height')
+ assert_hash256_str(tx_item['tx_hash'])
+ if height in (-1, 0):
+ assert_dict_contains_field(tx_item, field_name='fee')
+ assert_non_negative_integer(tx_item['fee'])
+ prev_height = float("inf") # this ensures confirmed txs can't follow mempool txs
+ else:
+ # check monotonicity of heights
+ if height < prev_height:
+ raise RequestCorrupted(f'heights of confirmed txs must be in increasing order')
+ prev_height = height
+ if self.active_protocol_tuple >= (1, 6):
+ # enforce order of mempool txs
+ mempool_txs = [tx_item for tx_item in res if tx_item['height'] <= 0]
+ if mempool_txs != sorted(mempool_txs, key=lambda x: (-x['height'], bytes.fromhex(x['tx_hash']))):
+ raise RequestCorrupted(f'mempool txs not in canonical order')
+ hashes = set(map(lambda item: item['tx_hash'], res))
+ if len(hashes) != len(res):
+ # Either server is sending garbage... or maybe if server is race-prone
+ # a recently mined tx could be included in both last block and mempool?
+ # Still, it's simplest to just disregard the response.
+ raise RequestCorrupted(f"server history has non-unique txids for sh={sh}")
+ return res
+
+ async def listunspent_for_scripthash(self, sh: str) -> List[dict]:
+ if not is_hash256_str(sh):
+ raise Exception(f"{repr(sh)} is not a scripthash")
+ # do request
+ res = await self.session.send_request('blockchain.scripthash.listunspent', [sh])
+ # check response
+ assert_list_or_tuple(res)
+ for utxo_item in res:
+ assert_dict_contains_field(utxo_item, field_name='tx_pos')
+ assert_dict_contains_field(utxo_item, field_name='value')
+ assert_dict_contains_field(utxo_item, field_name='tx_hash')
+ assert_dict_contains_field(utxo_item, field_name='height')
+ assert_non_negative_integer(utxo_item['tx_pos'])
+ assert_non_negative_integer(utxo_item['value'])
+ assert_non_negative_integer(utxo_item['height'])
+ assert_hash256_str(utxo_item['tx_hash'])
+ return res
+
+ async def get_balance_for_scripthash(self, sh: str) -> dict:
+ if not is_hash256_str(sh):
+ raise Exception(f"{repr(sh)} is not a scripthash")
+ # do request
+ res = await self.session.send_request('blockchain.scripthash.get_balance', [sh])
+ # check response
+ assert_dict_contains_field(res, field_name='confirmed')
+ assert_dict_contains_field(res, field_name='unconfirmed')
+ assert_non_negative_integer(res['confirmed'])
+ assert_integer(res['unconfirmed'])
+ return res
+
+ async def get_txid_from_txpos(self, tx_height: int, tx_pos: int, merkle: bool):
+ if not is_non_negative_integer(tx_height):
+ raise Exception(f"{repr(tx_height)} is not a block height")
+ if not is_non_negative_integer(tx_pos):
+ raise Exception(f"{repr(tx_pos)} should be non-negative integer")
+ # do request
+ res = await self.session.send_request(
+ 'blockchain.transaction.id_from_pos',
+ [tx_height, tx_pos, merkle],
+ )
+ # check response
+ if merkle:
+ assert_dict_contains_field(res, field_name='tx_hash')
+ assert_dict_contains_field(res, field_name='merkle')
+ assert_hash256_str(res['tx_hash'])
+ assert_list_or_tuple(res['merkle'])
+ for node_hash in res['merkle']:
+ assert_hash256_str(node_hash)
+ else:
+ assert_hash256_str(res)
+ return res
+
+ async def get_fee_histogram(self) -> Sequence[Tuple[Union[float, int], int]]:
+ # do request
+ res = await self.session.send_request('mempool.get_fee_histogram')
+ # check response
+ assert_list_or_tuple(res)
+ prev_fee = float('inf')
+ for fee, s in res:
+ assert_non_negative_int_or_float(fee)
+ assert_non_negative_integer(s)
+ if fee >= prev_fee: # check monotonicity
+ raise RequestCorrupted(f'fees must be in decreasing order')
+ prev_fee = fee
+ return res
+
+ async def get_server_banner(self) -> str:
+ # do request
+ res = await self.session.send_request('server.banner')
+ # check response
+ if not isinstance(res, str):
+ raise RequestCorrupted(f'{res!r} should be a str')
+ return res
+
+ async def get_donation_address(self) -> str:
+ # do request
+ res = await self.session.send_request('server.donation_address')
+ # check response
+ if not res: # ignore empty string
+ return ''
+ if not isinstance(res, str):
+ raise RequestCorrupted(f'{res!r} should be a str')
+ address = res.removeprefix('bitcoin:')
+ if not bitcoin.is_address(address):
+ # note: do not hard-fail -- allow server to use future-type
+ # bitcoin address we do not recognize
+ self.logger.info(f"invalid donation address from server: {repr(res)}")
+ return ''
+ return address
+
+ async def get_relay_fee(self) -> int:
+ """Returns the min relay feerate in sat/kbyte."""
+ # do request
+ if self.active_protocol_tuple >= (1, 6):
+ res = await self.session.send_request('mempool.get_info')
+ minrelaytxfee = assert_dict_contains_field(res, field_name='minrelaytxfee')
+ else:
+ minrelaytxfee = await self.session.send_request('blockchain.relayfee')
+ # check response
+ assert_non_negative_int_or_float(minrelaytxfee)
+ relayfee = int(minrelaytxfee * bitcoin.COIN)
+ relayfee = max(0, relayfee)
+ return relayfee
+
+ async def get_estimatefee(self, num_blocks: int) -> int:
+ """Returns a feerate estimate for getting confirmed within
+ num_blocks blocks, in sat/kbyte.
+ Returns -1 if the server could not provide an estimate.
+ """
+ if not is_non_negative_integer(num_blocks):
+ raise Exception(f"{repr(num_blocks)} is not a num_blocks")
+ # do request
+ try:
+ res = await self.session.send_request('blockchain.estimatefee', [num_blocks])
+ except aiorpcx.jsonrpc.ProtocolError as e:
+ # The protocol spec says the server itself should already have returned -1
+ # if it cannot provide an estimate, however apparently "electrs" does not conform
+ # and sends an error instead. Convert it here:
+ if "cannot estimate fee" in e.message:
+ res = -1
+ else:
+ raise
+ except aiorpcx.jsonrpc.RPCError as e:
+ # The protocol spec says the server itself should already have returned -1
+ # if it cannot provide an estimate. "Fulcrum" often sends:
+ # aiorpcx.jsonrpc.RPCError: (-32603, 'internal error: bitcoind request timed out')
+ if e.code == JSONRPC.INTERNAL_ERROR:
+ res = -1
+ else:
+ raise
+ # check response
+ if res != -1:
+ assert_non_negative_int_or_float(res)
+ res = int(res * bitcoin.COIN)
+ return res
+
+
+def _assert_header_does_not_check_against_any_chain(header: dict) -> None:
+ chain_bad = blockchain.check_header(header)
+ if chain_bad:
+ raise Exception('bad_header must not check!')
+
+
+def sanitize_tx_broadcast_response(server_msg) -> str:
+ # Unfortunately, bitcoind and hence the Electrum protocol doesn't return a useful error code.
+ # So, we use substring matching to grok the error message.
+ # server_msg is untrusted input so it should not be shown to the user. see #4968
+ server_msg = str(server_msg)
+ server_msg = server_msg.replace("\n", r"\n")
+
+ # https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/script/script_error.cpp
+ script_error_messages = {
+ r"Script evaluated without error but finished with a false/empty top stack element",
+ r"Script failed an OP_VERIFY operation",
+ r"Script failed an OP_EQUALVERIFY operation",
+ r"Script failed an OP_CHECKMULTISIGVERIFY operation",
+ r"Script failed an OP_CHECKSIGVERIFY operation",
+ r"Script failed an OP_NUMEQUALVERIFY operation",
+ r"Script is too big",
+ r"Push value size limit exceeded",
+ r"Operation limit exceeded",
+ r"Stack size limit exceeded",
+ r"Signature count negative or greater than pubkey count",
+ r"Pubkey count negative or limit exceeded",
+ r"Opcode missing or not understood",
+ r"Attempted to use a disabled opcode",
+ r"Operation not valid with the current stack size",
+ r"Operation not valid with the current altstack size",
+ r"OP_RETURN was encountered",
+ r"Invalid OP_IF construction",
+ r"Negative locktime",
+ r"Locktime requirement not satisfied",
+ r"Signature hash type missing or not understood",
+ r"Non-canonical DER signature",
+ r"Data push larger than necessary",
+ r"Only push operators allowed in signatures",
+ r"Non-canonical signature: S value is unnecessarily high",
+ r"Dummy CHECKMULTISIG argument must be zero",
+ r"OP_IF/NOTIF argument must be minimal",
+ r"Signature must be zero for failed CHECK(MULTI)SIG operation",
+ r"NOPx reserved for soft-fork upgrades",
+ r"Witness version reserved for soft-fork upgrades",
+ r"Taproot version reserved for soft-fork upgrades",
+ r"OP_SUCCESSx reserved for soft-fork upgrades",
+ r"Public key version reserved for soft-fork upgrades",
+ r"Public key is neither compressed or uncompressed",
+ r"Stack size must be exactly one after execution",
+ r"Extra items left on stack after execution",
+ r"Witness program has incorrect length",
+ r"Witness program was passed an empty witness",
+ r"Witness program hash mismatch",
+ r"Witness requires empty scriptSig",
+ r"Witness requires only-redeemscript scriptSig",
+ r"Witness provided for non-witness script",
+ r"Using non-compressed keys in segwit",
+ r"Invalid Schnorr signature size",
+ r"Invalid Schnorr signature hash type",
+ r"Invalid Schnorr signature",
+ r"Invalid Taproot control block size",
+ r"Too much signature validation relative to witness weight",
+ r"OP_CHECKMULTISIG(VERIFY) is not available in tapscript",
+ r"OP_IF/NOTIF argument must be minimal in tapscript",
+ r"Using OP_CODESEPARATOR in non-witness script",
+ r"Signature is found in scriptCode",
+ }
+ for substring in script_error_messages:
+ if substring in server_msg:
+ return substring
+ # https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/validation.cpp
+ # grep "REJECT_"
+ # grep "TxValidationResult"
+ # should come after script_error.cpp (due to e.g. "non-mandatory-script-verify-flag")
+ validation_error_messages = {
+ r"coinbase": None,
+ r"tx-size-small": None,
+ r"non-final": None,
+ r"txn-already-in-mempool": None,
+ r"txn-mempool-conflict": None,
+ r"txn-already-known": None,
+ r"non-BIP68-final": None,
+ r"bad-txns-nonstandard-inputs": None,
+ r"bad-witness-nonstandard": None,
+ r"bad-txns-too-many-sigops": None,
+ r"mempool min fee not met":
+ ("mempool min fee not met\n" +
+ _("Your transaction is paying a fee that is so low that the bitcoin node cannot "
+ "fit it into its mempool. The mempool is already full of hundreds of megabytes "
+ "of transactions that all pay higher fees. Try to increase the fee.")),
+ r"min relay fee not met": None,
+ r"absurdly-high-fee": None,
+ r"max-fee-exceeded": None,
+ r"too-long-mempool-chain": None,
+ r"bad-txns-spends-conflicting-tx": None,
+ r"insufficient fee": ("insufficient fee\n" +
+ _("Your transaction is trying to replace another one in the mempool but it "
+ "does not meet the rules to do so. Try to increase the fee.")),
+ r"too many potential replacements": None,
+ r"replacement-adds-unconfirmed": None,
+ r"mempool full": None,
+ r"non-mandatory-script-verify-flag": None,
+ r"mandatory-script-verify-flag-failed": None,
+ r"Transaction check failed": None,
+ }
+ for substring in validation_error_messages:
+ if substring in server_msg:
+ msg = validation_error_messages[substring]
+ return msg if msg else substring
+ # https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/rpc/rawtransaction.cpp
+ # https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/util/error.cpp
+ # https://github.com/bitcoin/bitcoin/blob/3f83c744ac28b700090e15b5dda2260724a56f49/src/common/messages.cpp#L126
+ # grep "RPC_TRANSACTION"
+ # grep "RPC_DESERIALIZATION_ERROR"
+ # grep "TransactionError"
+ rawtransaction_error_messages = {
+ r"Missing inputs": None,
+ r"Inputs missing or spent": None,
+ r"transaction already in block chain": None,
+ r"Transaction already in block chain": None,
+ r"Transaction outputs already in utxo set": None,
+ r"TX decode failed": None,
+ r"Peer-to-peer functionality missing or disabled": None,
+ r"Transaction rejected by AcceptToMemoryPool": None,
+ r"AcceptToMemoryPool failed": None,
+ r"Transaction rejected by mempool": None,
+ r"Mempool internal error": None,
+ r"Fee exceeds maximum configured by user": None,
+ r"Unspendable output exceeds maximum configured by user": None,
+ r"Transaction rejected due to invalid package": None,
+ }
+ for substring in rawtransaction_error_messages:
+ if substring in server_msg:
+ msg = rawtransaction_error_messages[substring]
+ return msg if msg else substring
+ # https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/consensus/tx_verify.cpp
+ # https://github.com/bitcoin/bitcoin/blob/c7ad94428ab6f54661d7a5441e1fdd0ebf034903/src/consensus/tx_check.cpp
+ # grep "REJECT_"
+ # grep "TxValidationResult"
+ tx_verify_error_messages = {
+ r"bad-txns-vin-empty": None,
+ r"bad-txns-vout-empty": None,
+ r"bad-txns-oversize": None,
+ r"bad-txns-vout-negative": None,
+ r"bad-txns-vout-toolarge": None,
+ r"bad-txns-txouttotal-toolarge": None,
+ r"bad-txns-inputs-duplicate": None,
+ r"bad-cb-length": None,
+ r"bad-txns-prevout-null": None,
+ r"bad-txns-inputs-missingorspent":
+ ("bad-txns-inputs-missingorspent\n" +
+ _("You might have a local transaction in your wallet that this transaction "
+ "builds on top. You need to either broadcast or remove the local tx.")),
+ r"bad-txns-premature-spend-of-coinbase": None,
+ r"bad-txns-inputvalues-outofrange": None,
+ r"bad-txns-in-belowout": None,
+ r"bad-txns-fee-outofrange": None,
+ }
+ for substring in tx_verify_error_messages:
+ if substring in server_msg:
+ msg = tx_verify_error_messages[substring]
+ return msg if msg else substring
+ # https://github.com/bitcoin/bitcoin/blob/5bb64acd9d3ced6e6f95df282a1a0f8b98522cb0/src/policy/policy.cpp
+ # grep "reason ="
+ # should come after validation.cpp (due to "tx-size" vs "tx-size-small")
+ # should come after script_error.cpp (due to e.g. "version")
+ policy_error_messages = {
+ r"version": _("Transaction uses non-standard version."),
+ r"tx-size": _("The transaction was rejected because it is too large (in bytes)."),
+ r"scriptsig-size": None,
+ r"scriptsig-not-pushonly": None,
+ r"scriptpubkey":
+ ("scriptpubkey\n" +
+ _("Some of the outputs pay to a non-standard script.")),
+ r"bare-multisig": None,
+ r"dust":
+ (_("Transaction could not be broadcast due to dust outputs.\n"
+ "Some of the outputs are too small in value, probably lower than 1000 satoshis.\n"
+ "Check the units, make sure you haven't confused e.g. mBTC and BTC.")),
+ r"multi-op-return": _("The transaction was rejected because it contains multiple OP_RETURN outputs."),
+ }
+ for substring in policy_error_messages:
+ if substring in server_msg:
+ msg = policy_error_messages[substring]
+ return msg if msg else substring
+ # otherwise:
+ return _("Unknown error")
diff --git a/electrum/invoices.py b/electrum/invoices.py
new file mode 100644
index 000000000000..55ad4fc5f0b9
--- /dev/null
+++ b/electrum/invoices.py
@@ -0,0 +1,353 @@
+import time
+from typing import TYPE_CHECKING, List, Optional, Union, Dict, Any, Sequence
+from decimal import Decimal
+
+import attr
+
+from .stored_dict import StoredObject, stored_at
+from .i18n import _
+from .util import age, InvoiceError, format_satoshis
+from .bip21 import create_bip21_uri
+from .lnutil import hex_to_bytes
+from .bolt11 import decode_bolt11_invoice, BOLT11Addr
+from . import constants
+from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
+from .bitcoin import address_to_script
+from .transaction import PartialTxOutput
+from .crypto import sha256d
+
+
+# convention: 'invoices' = outgoing , 'request' = incoming
+
+# status of payment requests
+PR_UNPAID = 0 # if onchain: invoice amt not reached by txs in mempool+chain. if LN: invoice not paid.
+PR_EXPIRED = 1 # invoice is unpaid and expiry time reached
+PR_UNKNOWN = 2 # e.g. invoice not found
+PR_PAID = 3 # if onchain: paid and mined (1 conf). if LN: invoice is paid.
+PR_INFLIGHT = 4 # only for LN. payment attempt in progress
+PR_FAILED = 5 # only for LN. we attempted to pay it, but all attempts failed
+PR_ROUTING = 6 # only for LN. *unused* atm.
+PR_UNCONFIRMED = 7 # only onchain. invoice is satisfied but tx is not mined yet.
+PR_BROADCASTING = 8 # onchain, tx is being broadcast
+PR_BROADCAST = 9 # onchain, tx was broadcast, is not yet in our history
+
+pr_color = {
+ PR_UNPAID: (.7, .7, .7, 1),
+ PR_PAID: (.2, .9, .2, 1),
+ PR_UNKNOWN: (.7, .7, .7, 1),
+ PR_EXPIRED: (.9, .2, .2, 1),
+ PR_INFLIGHT: (.9, .6, .3, 1),
+ PR_FAILED: (.9, .2, .2, 1),
+ PR_ROUTING: (.9, .6, .3, 1),
+ PR_BROADCASTING: (.9, .6, .3, 1),
+ PR_BROADCAST: (.9, .6, .3, 1),
+ PR_UNCONFIRMED: (.9, .6, .3, 1),
+}
+
+
+def pr_tooltips():
+ return {
+ PR_UNPAID: _('Unpaid'),
+ PR_PAID: _('Paid'),
+ PR_UNKNOWN: _('Unknown'),
+ PR_EXPIRED: _('Expired'),
+ PR_INFLIGHT: _('In progress'),
+ PR_BROADCASTING: _('Broadcasting'),
+ PR_BROADCAST: _('Broadcast successfully'),
+ PR_FAILED: _('Failed'),
+ PR_ROUTING: _('Computing route...'),
+ PR_UNCONFIRMED: _('Unconfirmed'),
+ }
+
+
+def pr_expiration_values():
+ return {
+ 0: _('Never'),
+ 10*60: _('10 minutes'),
+ 60*60: _('1 hour'),
+ 24*60*60: _('1 day'),
+ 7*24*60*60: _('1 week'),
+ }
+
+
+PR_DEFAULT_EXPIRATION_WHEN_CREATING = 24*60*60 # 1 day
+assert PR_DEFAULT_EXPIRATION_WHEN_CREATING in pr_expiration_values()
+
+
+def _decode_outputs(outputs) -> Optional[List[PartialTxOutput]]:
+ if outputs is None:
+ return None
+ ret = []
+ for output in outputs:
+ if not isinstance(output, PartialTxOutput):
+ output = PartialTxOutput.from_legacy_tuple(*output)
+ ret.append(output)
+ return ret
+
+
+# hack: BOLT-11 is not really clear on what an expiry of 0 means.
+# It probably interprets it as 0 seconds, so already expired...
+# Our higher level invoices code however uses 0 for "never".
+# Hence set some high expiration here
+LN_EXPIRY_NEVER = 100 * 365 * 24 * 60 * 60 # 100 years
+
+
+@attr.s
+class BaseInvoice(StoredObject):
+ """
+ Base class for Invoice and Request
+ In the code, we use 'invoice' for outgoing payments, and 'request' for incoming payments.
+
+ TODO this class is getting too complicated for "attrs"... maybe we should rewrite it without.
+ """
+
+ # mandatory fields
+ amount_msat = attr.ib( # can be '!' or None
+ kw_only=True, on_setattr=attr.setters.validate) # type: Optional[Union[int, str]]
+ message = attr.ib(type=str, kw_only=True)
+ time = attr.ib( # timestamp of the invoice
+ type=int, kw_only=True, validator=attr.validators.instance_of(int), on_setattr=attr.setters.validate)
+ exp = attr.ib( # expiration delay (relative). 0 means never
+ type=int, kw_only=True, validator=attr.validators.instance_of(int), on_setattr=attr.setters.validate)
+
+ # optional fields.
+ # an request (incoming) can be satisfied onchain, using lightning or using a swap
+ # an invoice (outgoing) is constructed from a source: bip21, lnaddr
+
+ # onchain only
+ outputs = attr.ib(kw_only=True, converter=_decode_outputs) # type: Optional[List[PartialTxOutput]]
+ height = attr.ib( # only for receiving
+ type=int, kw_only=True, validator=attr.validators.instance_of(int), on_setattr=attr.setters.validate)
+
+ # (unused) historical bip70 invoice data, for BIP70 invoices paid in the past
+ bip70 = attr.ib(type=str, kw_only=True, default=None) # type: Optional[str]
+
+ def is_lightning(self) -> bool:
+ raise NotImplementedError()
+
+ def get_address(self) -> Optional[str]:
+ """returns the first address, to be displayed in GUI"""
+ raise NotImplementedError()
+
+ @property
+ def rhash(self) -> str:
+ raise NotImplementedError()
+
+ def get_status_str(self, status):
+ status_str = pr_tooltips()[status]
+ if status == PR_UNPAID:
+ if self.exp > 0 and self.exp != LN_EXPIRY_NEVER:
+ expiration = self.get_expiration_date()
+ status_str = _('Expires') + ' ' + age(expiration, include_seconds=True)
+ return status_str
+
+ def get_outputs(self) -> Sequence[PartialTxOutput]:
+ outputs = self.outputs or []
+ if not outputs:
+ address = self.get_address()
+ amount = self.get_amount_sat()
+ if address and amount is not None:
+ outputs = [PartialTxOutput.from_address_and_value(address, int(amount))]
+ return outputs
+
+ def get_expiration_date(self):
+ # 0 means never
+ return self.exp + self.time if self.exp else 0
+
+ @staticmethod
+ def _get_cur_time(): # for unit tests
+ return time.time()
+
+ def has_expired(self) -> bool:
+ exp = self.get_expiration_date()
+ return bool(exp) and exp < self._get_cur_time()
+
+ def get_amount_msat(self) -> Union[int, str, None]:
+ return self.amount_msat
+
+ def get_time(self):
+ return self.time
+
+ def get_message(self):
+ return self.message
+
+ def get_amount_sat(self) -> Union[int, str, None]:
+ """
+ Returns an integer satoshi amount, or '!' or None.
+ Callers who need msat precision should call get_amount_msat()
+ """
+ amount_msat = self.amount_msat
+ if amount_msat in [None, "!"]:
+ return amount_msat
+ return int(amount_msat // 1000)
+
+ def set_amount_msat(self, amount_msat: Union[int, str]) -> None:
+ """The GUI uses this to fill the amount for a zero-amount invoice."""
+ if amount_msat == "!":
+ amount_sat = amount_msat
+ else:
+ assert isinstance(amount_msat, int), f"{amount_msat=!r}"
+ assert amount_msat >= 0, amount_msat
+ amount_sat = (amount_msat // 1000) + int(amount_msat % 1000 > 0) # round up
+ if outputs := self.outputs:
+ assert len(self.outputs) == 1, len(self.outputs)
+ self.outputs = [PartialTxOutput(scriptpubkey=outputs[0].scriptpubkey, value=amount_sat)]
+ self.amount_msat = amount_msat
+
+ @amount_msat.validator
+ def _validate_amount(self, attribute, value):
+ if value is None:
+ return
+ if isinstance(value, int):
+ if not (0 <= value <= TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN * 1000):
+ raise InvoiceError(f"amount is out-of-bounds: {value!r} msat")
+ elif isinstance(value, str):
+ if value != '!':
+ raise InvoiceError(f"unexpected amount: {value!r}")
+ else:
+ raise InvoiceError(f"unexpected amount: {value!r}")
+
+ @classmethod
+ def from_bech32(cls, invoice: str) -> 'Invoice':
+ """Constructs Invoice object from BOLT-11 string.
+ Might raise InvoiceError.
+ """
+ try:
+ lnaddr = decode_bolt11_invoice(invoice)
+ except Exception as e:
+ raise InvoiceError(e) from e
+ amount_msat = lnaddr.get_amount_msat()
+ timestamp = lnaddr.date
+ exp_delay = lnaddr.get_expiry()
+ message = lnaddr.get_description()
+ return Invoice(
+ message=message,
+ amount_msat=amount_msat,
+ time=timestamp,
+ exp=exp_delay,
+ outputs=None,
+ height=0,
+ lightning_invoice=invoice,
+ )
+
+ def get_id(self) -> str:
+ if self.is_lightning():
+ return self.rhash
+ else: # on-chain
+ return get_id_from_onchain_outputs(outputs=self.get_outputs(), timestamp=self.time)
+
+ def as_dict(self, status):
+ d = {
+ 'is_lightning': self.is_lightning(),
+ 'amount_BTC': format_satoshis(self.get_amount_sat()),
+ 'message': self.message,
+ 'timestamp': self.get_time(),
+ 'expiry': self.exp,
+ 'status': status,
+ 'status_str': self.get_status_str(status),
+ 'id': self.get_id(),
+ 'amount_sat': self.get_amount_sat(),
+ }
+ if self.is_lightning():
+ d['amount_msat'] = self.get_amount_msat()
+ return d
+
+
+@stored_at('/invoices/*')
+@attr.s
+class Invoice(BaseInvoice):
+ lightning_invoice = attr.ib(type=str, kw_only=True) # type: Optional[str]
+ __lnaddr = None
+ _broadcasting_status = None # can be None or PR_BROADCASTING or PR_BROADCAST
+
+ def is_lightning(self):
+ return self.lightning_invoice is not None
+
+ def get_broadcasting_status(self):
+ return self._broadcasting_status
+
+ def get_address(self) -> Optional[str]:
+ address = None
+ if self.outputs:
+ address = self.outputs[0].address if len(self.outputs) > 0 else None
+ if not address and self.is_lightning():
+ address = self._lnaddr.get_fallback_address() or None
+ return address
+
+ @property
+ def _lnaddr(self) -> BOLT11Addr:
+ if self.__lnaddr is None:
+ self.__lnaddr = decode_bolt11_invoice(self.lightning_invoice)
+ return self.__lnaddr
+
+ @property
+ def rhash(self) -> str:
+ assert self.is_lightning()
+ return self._lnaddr.paymenthash.hex()
+
+ @lightning_invoice.validator
+ def _validate_invoice_str(self, attribute, value):
+ if value is not None:
+ lnaddr = decode_bolt11_invoice(value) # this checks the str can be decoded
+ self.__lnaddr = lnaddr # save it, just to avoid having to recompute later
+
+ def can_be_paid_onchain(self) -> bool:
+ if self.is_lightning():
+ return bool(self._lnaddr.get_fallback_address()) or (bool(self.outputs))
+ else:
+ return True
+
+ def to_debug_json(self) -> Dict[str, Any]:
+ d = self.to_json()
+ d["lnaddr"] = self._lnaddr.to_debug_json()
+ return d
+
+
+@stored_at('/payment_requests/*')
+@attr.s
+class Request(BaseInvoice):
+ payment_hash = attr.ib(type=bytes, kw_only=True, converter=hex_to_bytes) # type: Optional[bytes]
+
+ def is_lightning(self):
+ return self.payment_hash is not None
+
+ def get_address(self) -> Optional[str]:
+ address = None
+ if self.outputs:
+ address = self.outputs[0].address if len(self.outputs) > 0 else None
+ return address
+
+ @property
+ def rhash(self) -> str:
+ assert self.is_lightning()
+ return self.payment_hash.hex()
+
+ def get_bip21_URI(
+ self,
+ *,
+ lightning_invoice: Optional[str] = None,
+ ) -> Optional[str]:
+ addr = self.get_address()
+ amount = self.get_amount_sat()
+ message = self.message
+ if amount is None and not message:
+ return
+ if amount:
+ amount = int(amount)
+ extra = {}
+ if self.time and self.exp:
+ extra['time'] = str(int(self.time))
+ extra['exp'] = str(int(self.exp))
+ if lightning_invoice:
+ extra['lightning'] = lightning_invoice
+ if not addr and lightning_invoice:
+ return "bitcoin:?lightning="+lightning_invoice
+ if not addr and not lightning_invoice:
+ return None
+ uri = create_bip21_uri(addr, amount, message, extra_query_params=extra)
+ return str(uri)
+
+
+def get_id_from_onchain_outputs(outputs: Sequence[PartialTxOutput], *, timestamp: int) -> str:
+ outputs_str = "\n".join(f"{txout.scriptpubkey.hex()}, {txout.value}" for txout in outputs)
+ return sha256d(outputs_str + "%d" % timestamp).hex()[0:10]
diff --git a/electrum/json_db.py b/electrum/json_db.py
new file mode 100644
index 000000000000..dbf3c3dd861e
--- /dev/null
+++ b/electrum/json_db.py
@@ -0,0 +1,302 @@
+#!/usr/bin/env python
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2019 The Electrum Developers
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+import threading
+import copy
+import json
+from typing import TYPE_CHECKING, Optional, Sequence, List, Union, Dict, Any
+
+import jsonpatch
+import jsonpointer
+
+from . import util
+from .util import WalletFileException, profiler, sticky_property
+from .logging import Logger
+from .stored_dict import StoredDict, _FLEX_KEY, registered_names, registered_keys, _convert_dict_key, _convert_dict_value
+
+
+if TYPE_CHECKING:
+ from .storage import WalletStorage
+
+
+# We monkeypatch exceptions in the jsonpatch package to ensure they do not contain secrets from the DB.
+# We often log exceptions and offer to send them to the crash reporter, so they must not contain secrets.
+jsonpointer.JsonPointerException.__str__ = lambda self: """(JPE) 'redacted'"""
+jsonpointer.JsonPointerException.__repr__ = lambda self: """"""
+setattr(jsonpointer.JsonPointerException, '__cause__', sticky_property(None))
+setattr(jsonpointer.JsonPointerException, '__context__', sticky_property(None))
+setattr(jsonpointer.JsonPointerException, '__suppress_context__', sticky_property(True))
+jsonpatch.JsonPatchException.__str__ = lambda self: """(JPE) 'redacted'"""
+jsonpatch.JsonPatchException.__repr__ = lambda self: """"""
+setattr(jsonpatch.JsonPatchException, '__cause__', sticky_property(None))
+setattr(jsonpatch.JsonPatchException, '__context__', sticky_property(None))
+setattr(jsonpatch.JsonPatchException, '__suppress_context__', sticky_property(True))
+
+
+def key_path(path: Sequence[_FLEX_KEY], key: _FLEX_KEY) -> str:
+ def to_str(x: _FLEX_KEY) -> str:
+ assert isinstance(x, _FLEX_KEY), repr(x)
+ assert x is not None
+ if isinstance(x, int):
+ return str(int(x))
+ else:
+ assert isinstance(x, str), f"unexpected key type for: {x!r}"
+ return x
+ items = [to_str(x) for x in path]
+ if key is not None:
+ items.append(to_str(key))
+ return '/'.join(items)
+
+
+def modifier(func):
+ def wrapper(self, *args, **kwargs):
+ with self.lock:
+ self._modified = True
+ return func(self, *args, **kwargs)
+ return wrapper
+
+def locked(func):
+ def wrapper(self, *args, **kwargs):
+ with self.lock:
+ return func(self, *args, **kwargs)
+ return wrapper
+
+
+
+
+class JsonDB(Logger):
+
+ def __init__(
+ self,
+ s: str,
+ *,
+ storage: Optional['WalletStorage'] = None,
+ encoder=None,
+ upgrader=None,
+ ):
+ Logger.__init__(self)
+ self.lock = threading.RLock()
+ self.storage = storage
+ self.encoder = encoder
+ self.pending_changes = [] # type: List[str]
+ self._modified = False
+ # load data
+ data = self.load_data(s)
+ if upgrader:
+ data, was_upgraded = upgrader(data)
+ self._modified |= was_upgraded
+ # convert json to python objects
+ data = self._convert_dict([], data)
+ # convert dict to StoredDict
+ self.data = StoredDict(data, self)
+ self.data.set_parent(key='', parent=None)
+ # write file in case there was a db upgrade
+ if self.storage and self.storage.file_exists():
+ self.write_and_force_consolidation()
+
+ def load_data(self, s: str) -> Dict[str, Any]:
+ if s == '':
+ return {}
+ try:
+ data = json.loads('[' + s + ']')
+ data, patches = data[0], data[1:]
+ except Exception:
+ if r := self.maybe_load_ast_data(s):
+ data, patches = r, []
+ elif r := self.maybe_load_incomplete_data(s):
+ data, patches = r, []
+ else:
+ raise WalletFileException("Cannot read wallet file. (parsing failed)")
+ if not isinstance(data, dict):
+ raise WalletFileException("Malformed wallet file (not dict)")
+ if patches:
+ # apply patches
+ self.logger.info('found %d patches'%len(patches))
+ patch = jsonpatch.JsonPatch(patches)
+ data = patch.apply(data)
+ self.set_modified(True)
+ return data
+
+ def maybe_load_ast_data(self, s) ->Dict[str, Any]:
+ """ for old wallets """
+ try:
+ import ast
+ d = ast.literal_eval(s)
+ labels = d.get('labels', {})
+ except Exception as e:
+ return
+ data = {}
+ for key, value in d.items():
+ try:
+ json.dumps(key)
+ json.dumps(value)
+ except Exception:
+ self.logger.info(f'Failed to convert label to json format: {key}')
+ continue
+ data[key] = value
+ # json roundtrip: recursively converts int keys to str
+ return json.loads(json.dumps(data))
+
+ def maybe_load_incomplete_data(self, s):
+ n = s.count('{') - s.count('}')
+ i = len(s)
+ while n > 0 and i > 0:
+ i = i - 1
+ if s[i] == '{':
+ n = n - 1
+ if s[i] == '}':
+ n = n + 1
+ if n == 0:
+ s = s[0:i]
+ assert s[-2:] == ',\n'
+ self.logger.info('found incomplete data {s[i:]}')
+ return self.load_data(s[0:-2])
+
+ def set_modified(self, b):
+ with self.lock:
+ self._modified = b
+
+ def modified(self):
+ return self._modified
+
+ @locked
+ def add_patch(self, patch):
+ self.pending_changes.append(json.dumps(patch, cls=self.encoder))
+ self.set_modified(True)
+
+ def add(self, path, key: _FLEX_KEY, value) -> None:
+ assert isinstance(key, _FLEX_KEY), repr(key)
+ self.add_patch({'op': 'add', 'path': key_path(path, key), 'value': value})
+
+ def replace(self, path, key: _FLEX_KEY, value) -> None:
+ assert isinstance(key, _FLEX_KEY), repr(key)
+ self.add_patch({'op': 'replace', 'path': key_path(path, key), 'value': value})
+
+ def remove(self, path, key: _FLEX_KEY) -> None:
+ assert isinstance(key, _FLEX_KEY), repr(key)
+ self.add_patch({'op': 'remove', 'path': key_path(path, key)})
+
+ @locked
+ def get(self, key, default=None):
+ v = self.data.get(key)
+ if v is None:
+ v = default
+ return v
+
+ @modifier
+ def put(self, key, value):
+ try:
+ json.dumps(key, cls=self.encoder)
+ json.dumps(value, cls=self.encoder)
+ except Exception:
+ self.logger.info(f"json error: cannot save {repr(key)} ({repr(value)})")
+ return False
+ if value is not None:
+ if self.data.get(key) != value:
+ self.data[key] = copy.deepcopy(value)
+ return True
+ elif key in self.data:
+ self.data.pop(key)
+ return True
+ return False
+
+ @locked
+ def get_dict(self, name) -> dict:
+ # Warning: interacts un-intuitively with 'put': certain parts
+ # of 'data' will have pointers saved as separate variables.
+ if name not in self.data:
+ self.data[name] = {}
+ return self.data[name]
+
+ @locked
+ def get_stored_item(self, key, default) -> dict:
+ if key not in self.data:
+ self.data[key] = default
+ return self.data[key]
+
+ @locked
+ def dump(self, *, human_readable: bool = True) -> str:
+ """Serializes the DB as a string.
+ 'human_readable': makes the json indented and sorted, but this is ~2x slower
+ """
+ return json.dumps(
+ self.data,
+ indent=4 if human_readable else None,
+ sort_keys=bool(human_readable),
+ cls=self.encoder,
+ )
+
+ def _should_convert_to_stored_dict(self, key) -> bool:
+ return True
+
+ def _convert_dict_key(self, path: List[str], key: str) -> _FLEX_KEY:
+ return _convert_dict_key(path, key)
+
+ def _convert_dict_value(self, path: List[str], v) -> Any:
+ v = _convert_dict_value(path, v)
+ if isinstance(v, dict):
+ v = self._convert_dict(path, v)
+ return v
+
+ def _convert_dict(self, path: List[str], data: dict):
+ # recursively convert json dict to StoredDict
+ assert all(isinstance(x, str) for x in path), repr(path)
+ d = {}
+ for k, v in list(data.items()):
+ child_path = path + [k]
+ k = self._convert_dict_key(path, k)
+ v = self._convert_dict_value(child_path, v)
+ d[k] = v
+ return d
+
+ @locked
+ def write(self):
+ if self.storage.should_do_full_write_next():
+ self.write_and_force_consolidation()
+ else:
+ self._append_pending_changes()
+
+ @locked
+ def _append_pending_changes(self):
+ if threading.current_thread().daemon:
+ raise Exception('daemon thread cannot write db')
+ if not self.pending_changes:
+ self.logger.info('no pending changes')
+ return
+ self.logger.info(f'appending {len(self.pending_changes)} pending changes')
+ s = ''.join([',\n' + x for x in self.pending_changes])
+ self.storage.append(s)
+ self.pending_changes = []
+
+ @locked
+ @profiler
+ def write_and_force_consolidation(self):
+ if threading.current_thread().daemon:
+ raise Exception('daemon thread cannot write db')
+ if not self.modified():
+ return
+ json_str = self.dump(human_readable=not self.storage.is_encrypted())
+ self.storage.write(json_str)
+ self.pending_changes = []
+ self.set_modified(False)
diff --git a/electrum/keystore.py b/electrum/keystore.py
new file mode 100644
index 000000000000..0d7fd8e34f10
--- /dev/null
+++ b/electrum/keystore.py
@@ -0,0 +1,1264 @@
+#!/usr/bin/env python2
+# -*- mode: python -*-
+#
+# Electrum - lightweight Bitcoin client
+# Copyright (C) 2016 The Electrum developers
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from unicodedata import normalize
+import hashlib
+import re
+import copy
+from typing import Tuple, TYPE_CHECKING, Union, Sequence, Optional, Dict, List, NamedTuple, Any, Type
+from functools import wraps
+from abc import ABC, abstractmethod
+
+import electrum_ecc as ecc
+from electrum_ecc import string_to_number
+
+from . import bitcoin, constants, bip32
+from .bitcoin import deserialize_privkey, serialize_privkey, BaseDecodeError
+from .transaction import Transaction, PartialTransaction, PartialTxInput, PartialTxOutput, TxInput
+from .bip32 import (convert_bip32_strpath_to_intpath, BIP32_PRIME,
+ is_xpub, is_xprv, BIP32Node, normalize_bip32_derivation,
+ convert_bip32_intpath_to_strpath, is_xkey_consistent_with_key_origin_info,
+ KeyOriginInfo)
+from .descriptor import PubkeyProvider
+from . import crypto
+from .crypto import (pw_decode, pw_encode, sha256, sha256d, PW_HASH_VERSION_LATEST,
+ SUPPORTED_PW_HASH_VERSIONS, UnsupportedPasswordHashVersion, hash_160,
+ CiphertextFormatError)
+from .util import (InvalidPassword, WalletFileException,
+ BitcoinException, bfh, inv_dict, is_hex_str)
+from .mnemonic import Mnemonic, Wordlist, calc_seed_type, is_seed
+from .plugin import run_hook
+from .logging import Logger
+from .lrucache import LRUCache
+
+if TYPE_CHECKING:
+ from .gui.common_qt.util import TaskThread
+ from .hw_wallet import HW_PluginBase, HardwareClientBase, HardwareHandlerBase
+ from .wallet_db import WalletDB
+ from .plugin import Device
+
+
+class CannotDerivePubkey(Exception): pass
+class ScriptTypeNotSupported(Exception): pass
+
+
+def also_test_none_password(check_password_fn):
+ """Decorator for check_password, simply to give a friendlier exception if
+ check_password(x) is called on a keystore that does not have a password set.
+ """
+ @wraps(check_password_fn)
+ def wrapper(self: 'Software_KeyStore', *args):
+ password = args[0]
+ try:
+ return check_password_fn(self, password)
+ except (CiphertextFormatError, InvalidPassword) as e:
+ if password is not None:
+ try:
+ check_password_fn(self, None)
+ except Exception:
+ pass
+ else:
+ raise InvalidPassword("password given but keystore has no password") from e
+ raise
+ return wrapper
+
+
+class KeyStore(Logger, ABC):
+ type: str
+
+ def __init__(self):
+ Logger.__init__(self)
+ self.is_requesting_to_be_rewritten_to_wallet_file = False # type: bool
+
+ def has_seed(self) -> bool:
+ return False
+
+ def is_watching_only(self) -> bool:
+ return False
+
+ def can_import(self) -> bool:
+ return False
+
+ def get_type_text(self) -> str:
+ return f'{self.type}'
+
+ @abstractmethod
+ def may_have_password(self) -> bool:
+ """Returns whether the keystore can be encrypted with a password."""
+ pass
+
+ def _get_tx_derivations(self, tx: 'PartialTransaction') -> Dict[bytes, Union[Sequence[int], str]]:
+ keypairs = {}
+ for txin in tx.inputs():
+ keypairs.update(self._get_txin_derivations(txin))
+ return keypairs
+
+ def _get_txin_derivations(self, txin: 'PartialTxInput') -> Dict[bytes, Union[Sequence[int], str]]:
+ if txin.is_complete():
+ return {}
+ keypairs = {}
+ for pubkey in txin.pubkeys:
+ if pubkey in txin.sigs_ecdsa:
+ # this pubkey already signed
+ continue
+ derivation = self.get_pubkey_derivation(pubkey, txin)
+ if not derivation:
+ continue
+ keypairs[pubkey] = derivation
+ return keypairs
+
+ def can_sign(self, tx: 'Transaction', *, ignore_watching_only: bool = False) -> bool:
+ """Returns whether this keystore could sign *something* in this tx."""
+ if not ignore_watching_only and self.is_watching_only():
+ return False
+ if not isinstance(tx, PartialTransaction):
+ return False
+ return bool(self._get_tx_derivations(tx))
+
+ def can_sign_txin(self, txin: 'TxInput', *, ignore_watching_only: bool = False) -> bool:
+ """Returns whether this keystore could sign this txin."""
+ if not ignore_watching_only and self.is_watching_only():
+ return False
+ if not isinstance(txin, PartialTxInput):
+ return False
+ return bool(self._get_txin_derivations(txin))
+
+ def ready_to_sign(self) -> bool:
+ return not self.is_watching_only()
+
+ @abstractmethod
+ def dump(self) -> dict[str, Any]:
+ pass
+
+ @abstractmethod
+ def is_deterministic(self) -> bool:
+ pass
+
+ @abstractmethod
+ def sign_message(
+ self,
+ sequence: 'AddressIndexGeneric',
+ message: str,
+ password,
+ *,
+ script_type: Optional[str] = None,
+ ) -> bytes:
+ pass
+
+ @abstractmethod
+ def decrypt_message(self, sequence: 'AddressIndexGeneric', message, password) -> bytes:
+ pass
+
+ @abstractmethod
+ def sign_transaction(self, tx: 'PartialTransaction', password) -> None:
+ pass
+
+ @abstractmethod
+ def get_pubkey_derivation(self, pubkey: bytes,
+ txinout: Union['PartialTxInput', 'PartialTxOutput'],
+ *, only_der_suffix=True) \
+ -> Union[Sequence[int], str, None]:
+ """Returns either a derivation int-list if the pubkey can be HD derived from this keystore,
+ the pubkey itself (hex) if the pubkey belongs to the keystore but not HD derived,
+ or None if the pubkey is unrelated.
+ """
+ pass
+
+ @abstractmethod
+ def get_pubkey_provider(self, sequence: 'AddressIndexGeneric') -> Optional[PubkeyProvider]:
+ pass
+
+ def find_my_pubkey_in_txinout(
+ self, txinout: Union['PartialTxInput', 'PartialTxOutput'],
+ *, only_der_suffix: bool = False
+ ) -> Tuple[Optional[bytes], Optional[List[int]]]:
+ # note: we assume that this cosigner only has one pubkey in this txin/txout
+ for pubkey in txinout.bip32_paths:
+ path = self.get_pubkey_derivation(pubkey, txinout, only_der_suffix=only_der_suffix)
+ if path and not isinstance(path, (str, bytes)):
+ return pubkey, list(path)
+ return None, None
+
+ def can_have_deterministic_lightning_xprv(self) -> bool:
+ return False
+
+ def has_support_for_slip_19_ownership_proofs(self) -> bool:
+ return False
+
+ def add_slip_19_ownership_proofs_to_tx(self, tx: 'PartialTransaction', *, password) -> None:
+ raise NotImplementedError()
+
+
+class Software_KeyStore(KeyStore):
+
+ def __init__(self, d: dict):
+ KeyStore.__init__(self)
+ self.pw_hash_version = d.get('pw_hash_version', 1)
+ if self.pw_hash_version not in SUPPORTED_PW_HASH_VERSIONS:
+ raise UnsupportedPasswordHashVersion(self.pw_hash_version)
+
+ def may_have_password(self):
+ return not self.is_watching_only()
+
+ def sign_message(self, sequence, message, password, *, script_type=None) -> bytes:
+ privkey, compressed = self.get_private_key(sequence, password)
+ key = ecc.ECPrivkey(privkey)
+ return bitcoin.ecdsa_sign_usermessage(key, message, is_compressed=compressed)
+
+ def decrypt_message(self, sequence, message, password) -> bytes:
+ privkey, compressed = self.get_private_key(sequence, password)
+ ec = ecc.ECPrivkey(privkey)
+ decrypted = crypto.ecies_decrypt_message(ec, message)
+ return decrypted
+
+ def sign_transaction(self, tx, password):
+ if self.is_watching_only():
+ return
+ # Raise if password is not correct.
+ self.check_password(password)
+ # Add private keys
+ keypairs = {}
+ pubkey_to_deriv_map = self._get_tx_derivations(tx)
+ for pubkey, deriv in pubkey_to_deriv_map.items():
+ privkey, is_compressed = self.get_private_key(deriv, password)
+ keypairs[pubkey] = privkey
+ # Sign
+ if keypairs:
+ tx.sign(keypairs)
+
+ @abstractmethod
+ def update_password(self, old_password, new_password) -> None:
+ pass
+
+ @abstractmethod
+ def check_password(self, password: Optional[str]) -> None:
+ """Raises InvalidPassword if password is not correct"""
+ pass
+
+ @abstractmethod
+ def get_private_key(self, sequence: 'AddressIndexGeneric', password) -> Tuple[bytes, bool]:
+ """Returns (privkey, is_compressed)"""
+ pass
+
+
+class Imported_KeyStore(Software_KeyStore):
+ # keystore for imported private keys
+
+ type = 'imported'
+
+ def __init__(self, d: dict):
+ Software_KeyStore.__init__(self, d)
+ self.keypairs = d.get('keypairs', {}) # type: Dict[str, str]
+
+ def is_deterministic(self):
+ return False
+
+ def dump(self):
+ return {
+ 'type': self.type,
+ 'keypairs': self.keypairs,
+ 'pw_hash_version': self.pw_hash_version,
+ }
+
+ def can_import(self):
+ return True
+
+ @also_test_none_password
+ def check_password(self, password):
+ pubkey = list(self.keypairs.keys())[0]
+ self.get_private_key(pubkey, password)
+
+ def import_privkey(self, sec: str, password) -> Tuple[str, str]:
+ txin_type, privkey, compressed = deserialize_privkey(sec)
+ pubkey = ecc.ECPrivkey(privkey).get_public_key_hex(compressed=compressed)
+ # re-serialize the key so the internal storage format is consistent
+ serialized_privkey = serialize_privkey(
+ privkey, compressed, txin_type, internal_use=True)
+ # NOTE: if the same pubkey is reused for multiple addresses (script types),
+ # there will only be one pubkey-privkey pair for it in self.keypairs,
+ # and the privkey will encode a txin_type but that txin_type cannot be trusted.
+ # Removing keys complicates this further.
+ self.keypairs[pubkey] = pw_encode(serialized_privkey, password, version=self.pw_hash_version)
+ return txin_type, pubkey
+
+ def import_private_keys(self, keys: Sequence[str], password: Optional[str]):
+ good_inputs = [] # type: List[Tuple[str, bytes]]
+ bad_keys = [] # type: List[Tuple[str, str]]
+ for key in keys:
+ try:
+ txin_type, pubkey = self.import_privkey(key, password)
+ except Exception as e:
+ bad_keys.append((key, 'invalid private key' + f': {e}'))
+ continue
+ if txin_type not in ('p2pkh', 'p2wpkh', 'p2wpkh-p2sh'):
+ bad_keys.append((key, 'not implemented type' + f': {txin_type}'))
+ continue
+ good_inputs.append((txin_type, pubkey))
+ return good_inputs, bad_keys
+
+ def delete_imported_key(self, key: str) -> None:
+ self.keypairs.pop(key)
+
+ def get_private_key(self, pubkey: str, password):
+ sec = pw_decode(self.keypairs[pubkey], password, version=self.pw_hash_version)
+ try:
+ txin_type, privkey, compressed = deserialize_privkey(sec)
+ except BaseDecodeError as e:
+ raise InvalidPassword() from e
+ if pubkey != ecc.ECPrivkey(privkey).get_public_key_hex(compressed=compressed):
+ raise InvalidPassword()
+ return privkey, compressed
+
+ def get_pubkey_derivation(self, pubkey, txin, *, only_der_suffix=True):
+ if pubkey.hex() in self.keypairs:
+ return pubkey.hex()
+ return None
+
+ def get_pubkey_provider(self, sequence: 'AddressIndexGeneric') -> Optional[PubkeyProvider]:
+ if sequence in self.keypairs:
+ return PubkeyProvider(
+ origin=None,
+ pubkey=sequence,
+ deriv_path=None,
+ )
+ return None
+
+ def update_password(self, old_password, new_password):
+ self.check_password(old_password)
+ if new_password == '':
+ new_password = None
+ for k, v in self.keypairs.items():
+ b = pw_decode(v, old_password, version=self.pw_hash_version)
+ c = pw_encode(b, new_password, version=PW_HASH_VERSION_LATEST)
+ self.keypairs[k] = c
+ self.pw_hash_version = PW_HASH_VERSION_LATEST
+
+
+class Deterministic_KeyStore(Software_KeyStore):
+
+ def __init__(self, d: dict):
+ Software_KeyStore.__init__(self, d)
+ self.seed = d.get('seed', '') # only electrum seeds
+ self.passphrase = d.get('passphrase', '')
+ self._seed_type = d.get('seed_type', None) # only electrum seeds
+
+ def is_deterministic(self):
+ return True
+
+ def dump(self):
+ d = {
+ 'type': self.type,
+ 'pw_hash_version': self.pw_hash_version,
+ }
+ if self.seed:
+ d['seed'] = self.seed
+ if self.passphrase:
+ d['passphrase'] = self.passphrase
+ if self._seed_type:
+ d['seed_type'] = self._seed_type
+ return d
+
+ def has_seed(self):
+ return bool(self.seed)
+
+ def get_seed_type(self) -> Optional[str]:
+ return self._seed_type
+
+ def is_watching_only(self):
+ return not self.has_seed()
+
+ @abstractmethod
+ def format_seed(self, seed: str) -> str:
+ pass
+
+ def add_seed(self, seed: str) -> None:
+ if self.seed:
+ raise Exception("a seed exists")
+ self.seed = self.format_seed(seed)
+ self._seed_type = calc_seed_type(seed) or None
+
+ def get_seed(self, password) -> str:
+ if not self.has_seed():
+ raise Exception("This wallet has no seed words")
+ return pw_decode(self.seed, password, version=self.pw_hash_version)
+
+ def get_passphrase(self, password) -> str:
+ if self.passphrase:
+ return pw_decode(self.passphrase, password, version=self.pw_hash_version)
+ else:
+ return ''
+
+
+class MasterPublicKeyMixin(ABC):
+
+ def __init__(self):
+ self._pubkey_cache = LRUCache(maxsize=10**4) # type: LRUCache[Sequence[int], bytes] # path->pubkey
+
+ @abstractmethod
+ def get_master_public_key(self) -> str:
+ pass
+
+ @abstractmethod
+ def get_derivation_prefix(self) -> Optional[str]:
+ """Returns to bip32 path from some root node to self.xpub
+ Note that the return value might be None; if it is unknown.
+ """
+ pass
+
+ @abstractmethod
+ def get_root_fingerprint(self) -> Optional[str]:
+ """Returns the bip32 fingerprint of the top level node.
+ This top level node is the node at the beginning of the derivation prefix,
+ i.e. applying the derivation prefix to it will result self.xpub
+ Note that the return value might be None; if it is unknown.
+ """
+ pass
+
+ @abstractmethod
+ def get_fp_and_derivation_to_be_used_in_partial_tx(
+ self,
+ der_suffix: Sequence[int],
+ *,
+ only_der_suffix: bool,
+ ) -> Tuple[bytes, Sequence[int]]:
+ """Returns fingerprint and derivation path corresponding to a derivation suffix.
+ The fingerprint is either the root fp or the intermediate fp, depending on what is available
+ and 'only_der_suffix', and the derivation path is adjusted accordingly.
+ """
+ pass
+
+ def get_key_origin_info(self) -> Optional[KeyOriginInfo]:
+ return None
+
+ def derive_pubkey(self, for_change: int, n: int) -> bytes:
+ key = (for_change, n)
+ if key not in self._pubkey_cache:
+ self._pubkey_cache[key] = self._derive_pubkey(*key)
+ return self._pubkey_cache[key]
+
+ @abstractmethod
+ def _derive_pubkey(self, for_change: int, n: int) -> bytes:
+ """Returns pubkey at given path.
+ May raise CannotDerivePubkey.
+ """
+ pass
+
+ def get_pubkey_derivation(
+ self,
+ pubkey: bytes,
+ txinout: Union['PartialTxInput', 'PartialTxOutput'],
+ *,
+ only_der_suffix=True,
+ ) -> Union[Sequence[int], str, None]:
+ EXPECTED_DER_SUFFIX_LEN = 2
+ def test_der_suffix_against_pubkey(der_suffix: Sequence[int], pubkey: bytes) -> bool:
+ if len(der_suffix) != EXPECTED_DER_SUFFIX_LEN:
+ return False
+ try:
+ if pubkey != self.derive_pubkey(*der_suffix):
+ return False
+ except CannotDerivePubkey:
+ return False
+ return True
+
+ if pubkey not in txinout.bip32_paths:
+ return None
+ fp_found, path_found = txinout.bip32_paths[pubkey]
+ der_suffix = None
+ full_path = None
+ # 1. try fp against our root
+ ks_root_fingerprint_hex = self.get_root_fingerprint()
+ ks_der_prefix_str = self.get_derivation_prefix()
+ ks_der_prefix = convert_bip32_strpath_to_intpath(ks_der_prefix_str) if ks_der_prefix_str else None
+ if (ks_root_fingerprint_hex is not None and ks_der_prefix is not None and
+ fp_found.hex() == ks_root_fingerprint_hex):
+ if path_found[:len(ks_der_prefix)] == ks_der_prefix:
+ der_suffix = path_found[len(ks_der_prefix):]
+ if not test_der_suffix_against_pubkey(der_suffix, pubkey):
+ der_suffix = None
+ # 2. try fp against our intermediate fingerprint
+ if (der_suffix is None and isinstance(self, Xpub) and
+ fp_found == self.get_bip32_node_for_xpub().calc_fingerprint_of_this_node()):
+ der_suffix = path_found
+ if not test_der_suffix_against_pubkey(der_suffix, pubkey):
+ der_suffix = None
+ # 3. hack/bruteforce: ignore fp and check pubkey anyway
+ # This is only to resolve the following scenario/problem:
+ # problem: if we don't know our root fp, but tx contains root fp and full path,
+ # we will miss the pubkey (false negative match). Though it might still work
+ # within gap limit due to tx.add_info_from_wallet overwriting the fields.
+ # Example: keystore has intermediate xprv without root fp; tx contains root fp and full path.
+ if der_suffix is None:
+ der_suffix = path_found[-EXPECTED_DER_SUFFIX_LEN:]
+ if not test_der_suffix_against_pubkey(der_suffix, pubkey):
+ der_suffix = None
+ # if all attempts/methods failed, we give up now:
+ if der_suffix is None:
+ return None
+ if ks_der_prefix is not None:
+ full_path = ks_der_prefix + list(der_suffix)
+ return der_suffix if only_der_suffix else full_path
+
+
+class Xpub(MasterPublicKeyMixin):
+
+ def __init__(self, *, derivation_prefix: str = None, root_fingerprint: str = None):
+ MasterPublicKeyMixin.__init__(self)
+ self.xpub = None
+ self.xpub_receive = None
+ self.xpub_change = None
+ self._xpub_bip32_node = None # type: Optional[BIP32Node]
+
+ # "key origin" info (subclass should persist these):
+ self._derivation_prefix = derivation_prefix # type: Optional[str]
+ self._root_fingerprint = root_fingerprint # type: Optional[str]
+
+ def get_master_public_key(self):
+ return self.xpub
+
+ def get_bip32_node_for_xpub(self) -> Optional[BIP32Node]:
+ if self._xpub_bip32_node is None:
+ if self.xpub is None:
+ return None
+ self._xpub_bip32_node = BIP32Node.from_xkey(self.xpub)
+ return self._xpub_bip32_node
+
+ def get_derivation_prefix(self) -> Optional[str]:
+ if self._derivation_prefix is None:
+ return None
+ return normalize_bip32_derivation(self._derivation_prefix)
+
+ def get_root_fingerprint(self) -> Optional[str]:
+ return self._root_fingerprint
+
+ def get_fp_and_derivation_to_be_used_in_partial_tx(
+ self,
+ der_suffix: Sequence[int],
+ *,
+ only_der_suffix: bool,
+ ) -> Tuple[bytes, Sequence[int]]:
+ fingerprint_hex = self.get_root_fingerprint()
+ der_prefix_str = self.get_derivation_prefix()
+ if not only_der_suffix and fingerprint_hex is not None and der_prefix_str is not None:
+ # use root fp, and true full path
+ fingerprint_bytes = bfh(fingerprint_hex)
+ der_prefix_ints = convert_bip32_strpath_to_intpath(der_prefix_str)
+ else:
+ # use intermediate fp, and claim der suffix is the full path
+ fingerprint_bytes = self.get_bip32_node_for_xpub().calc_fingerprint_of_this_node()
+ der_prefix_ints = convert_bip32_strpath_to_intpath('m')
+ der_full = der_prefix_ints + list(der_suffix)
+ return fingerprint_bytes, der_full
+
+ def get_xpub_to_be_used_in_partial_tx(self, *, only_der_suffix: bool) -> str:
+ assert self.xpub
+ fp_bytes, der_full = self.get_fp_and_derivation_to_be_used_in_partial_tx(der_suffix=[],
+ only_der_suffix=only_der_suffix)
+ bip32node = self.get_bip32_node_for_xpub()
+ depth = len(der_full)
+ child_number_int = der_full[-1] if len(der_full) >= 1 else 0
+ child_number_bytes = child_number_int.to_bytes(length=4, byteorder="big")
+ fingerprint = bytes(4) if depth == 0 else bip32node.fingerprint
+ bip32node = bip32node._replace(
+ depth=depth,
+ fingerprint=fingerprint,
+ child_number=child_number_bytes,
+ # only put plain xpubs (not ypub/zpub) in PSBTs:
+ xtype="standard",
+ )
+ return bip32node.to_xpub()
+
+ def get_key_origin_info(self) -> Optional[KeyOriginInfo]:
+ fp_bytes, der_full = self.get_fp_and_derivation_to_be_used_in_partial_tx(
+ der_suffix=[], only_der_suffix=False)
+ origin = KeyOriginInfo(fingerprint=fp_bytes, path=der_full)
+ return origin
+
+ def get_pubkey_provider(self, sequence: 'AddressIndexGeneric') -> Optional[PubkeyProvider]:
+ strpath = convert_bip32_intpath_to_strpath(sequence)
+ strpath = strpath[1:] # cut leading "m"
+ bip32node = self.get_bip32_node_for_xpub()
+ return PubkeyProvider(
+ origin=self.get_key_origin_info(),
+ pubkey=bip32node._replace(xtype="standard").to_xkey(),
+ deriv_path=strpath,
+ )
+
+ def add_key_origin_from_root_node(self, *, derivation_prefix: str, root_node: BIP32Node) -> None:
+ assert self.xpub
+ # try to derive ourselves from what we were given
+ child_node1 = root_node.subkey_at_private_derivation(derivation_prefix)
+ child_pubkey_bytes1 = child_node1.eckey.get_public_key_bytes(compressed=True)
+ child_node2 = self.get_bip32_node_for_xpub()
+ child_pubkey_bytes2 = child_node2.eckey.get_public_key_bytes(compressed=True)
+ if child_pubkey_bytes1 != child_pubkey_bytes2:
+ raise Exception("(xpub, derivation_prefix, root_node) inconsistency")
+ self.add_key_origin(derivation_prefix=derivation_prefix,
+ root_fingerprint=root_node.calc_fingerprint_of_this_node().hex().lower())
+
+ def add_key_origin(self, *, derivation_prefix: str = None, root_fingerprint: str = None) -> None:
+ assert self.xpub
+ if not (root_fingerprint is None or (is_hex_str(root_fingerprint) and len(root_fingerprint) == 8)):
+ raise Exception("root fp must be 8 hex characters")
+ derivation_prefix = normalize_bip32_derivation(derivation_prefix)
+ if not is_xkey_consistent_with_key_origin_info(self.xpub,
+ derivation_prefix=derivation_prefix,
+ root_fingerprint=root_fingerprint):
+ raise Exception("xpub inconsistent with provided key origin info")
+ if root_fingerprint is not None:
+ self._root_fingerprint = root_fingerprint
+ if derivation_prefix is not None:
+ self._derivation_prefix = derivation_prefix
+ self.is_requesting_to_be_rewritten_to_wallet_file = True
+
+ def _derive_pubkey(self, for_change: int, n: int) -> bytes:
+ for_change = int(for_change)
+ if for_change not in (0, 1):
+ raise CannotDerivePubkey("forbidden path")
+ xpub = self.xpub_change if for_change else self.xpub_receive
+ if xpub is None:
+ rootnode = self.get_bip32_node_for_xpub()
+ xpub = rootnode.subkey_at_public_derivation((for_change,)).to_xpub()
+ if for_change:
+ self.xpub_change = xpub
+ else:
+ self.xpub_receive = xpub
+ return self.get_pubkey_from_xpub(xpub, (n,))
+
+ @classmethod
+ def get_pubkey_from_xpub(cls, xpub: str, sequence) -> bytes:
+ node = BIP32Node.from_xkey(xpub).subkey_at_public_derivation(sequence)
+ return node.eckey.get_public_key_bytes(compressed=True)
+
+
+class BIP32_KeyStore(Xpub, Deterministic_KeyStore):
+
+ type = 'bip32'
+
+ def __init__(self, d: dict):
+ Xpub.__init__(self, derivation_prefix=d.get('derivation'), root_fingerprint=d.get('root_fingerprint'))
+ Deterministic_KeyStore.__init__(self, d)
+ self.xpub = d.get('xpub')
+ self.xprv = d.get('xprv')
+
+ def watching_only_keystore(self):
+ return BIP32_KeyStore({
+ 'xpub': self.xpub,
+ 'root_fingerprint': self.get_root_fingerprint(),
+ 'derivation': self.get_derivation_prefix(),
+ })
+
+ def format_seed(self, seed):
+ return ' '.join(seed.split())
+
+ def dump(self):
+ d = Deterministic_KeyStore.dump(self)
+ d['xpub'] = self.xpub
+ d['xprv'] = self.xprv
+ d['derivation'] = self.get_derivation_prefix()
+ d['root_fingerprint'] = self.get_root_fingerprint()
+ return d
+
+ def get_master_private_key(self, password) -> str:
+ return pw_decode(self.xprv, password, version=self.pw_hash_version)
+
+ @also_test_none_password
+ def check_password(self, password):
+ xprv = pw_decode(self.xprv, password, version=self.pw_hash_version)
+ try:
+ bip32node = BIP32Node.from_xkey(xprv)
+ except BaseDecodeError as e:
+ raise InvalidPassword() from e
+ if bip32node.chaincode != self.get_bip32_node_for_xpub().chaincode:
+ raise InvalidPassword()
+
+ def update_password(self, old_password, new_password):
+ self.check_password(old_password)
+ if new_password == '':
+ new_password = None
+ if self.has_seed():
+ decoded = self.get_seed(old_password)
+ self.seed = pw_encode(decoded, new_password, version=PW_HASH_VERSION_LATEST)
+ if self.passphrase:
+ decoded = self.get_passphrase(old_password)
+ self.passphrase = pw_encode(decoded, new_password, version=PW_HASH_VERSION_LATEST)
+ if self.xprv is not None:
+ b = pw_decode(self.xprv, old_password, version=self.pw_hash_version)
+ self.xprv = pw_encode(b, new_password, version=PW_HASH_VERSION_LATEST)
+ self.pw_hash_version = PW_HASH_VERSION_LATEST
+
+ def is_watching_only(self):
+ return self.xprv is None
+
+ def add_xpub(self, xpub: str) -> None:
+ assert is_xpub(xpub)
+ self.xpub = xpub
+ root_fingerprint, derivation_prefix = bip32.root_fp_and_der_prefix_from_xkey(xpub)
+ self.add_key_origin(derivation_prefix=derivation_prefix, root_fingerprint=root_fingerprint)
+
+ def add_xprv(self, xprv: str) -> None:
+ assert is_xprv(xprv)
+ self.xprv = xprv
+ self.add_xpub(bip32.xpub_from_xprv(xprv))
+
+ def add_xprv_from_seed(self, bip32_seed: bytes, *, xtype: str, derivation: str) -> None:
+ rootnode = BIP32Node.from_rootseed(bip32_seed, xtype=xtype)
+ node = rootnode.subkey_at_private_derivation(derivation)
+ self.add_xprv(node.to_xprv())
+ self.add_key_origin_from_root_node(derivation_prefix=derivation, root_node=rootnode)
+
+ def get_private_key(self, sequence: Sequence[int], password):
+ xprv = self.get_master_private_key(password)
+ node = BIP32Node.from_xkey(xprv).subkey_at_private_derivation(sequence)
+ pk = node.eckey.get_secret_bytes()
+ return pk, True
+
+ def can_have_deterministic_lightning_xprv(self):
+ if (self.get_seed_type() == 'segwit'
+ and self.get_bip32_node_for_xpub().xtype == 'p2wpkh'):
+ return True
+ return False
+
+ def get_lightning_xprv(self, password) -> str:
+ assert self.can_have_deterministic_lightning_xprv()
+ xprv = self.get_master_private_key(password)
+ rootnode = BIP32Node.from_xkey(xprv)
+ node = rootnode.subkey_at_private_derivation("m/67'/")
+ return node.to_xprv()
+
+class Old_KeyStore(MasterPublicKeyMixin, Deterministic_KeyStore):
+
+ type = 'old'
+
+ def __init__(self, d: dict):
+ MasterPublicKeyMixin.__init__(self)
+ Deterministic_KeyStore.__init__(self, d)
+ self.mpk = d.get('mpk') # type: Optional[str]
+ self._root_fingerprint = None
+
+ def watching_only_keystore(self):
+ return Old_KeyStore({'mpk': self.mpk})
+
+ def _get_hex_seed(self, password) -> str:
+ if not is_hex_str(self.seed) and password is None:
+ raise InvalidPassword()
+ hex_str = pw_decode(self.seed, password, version=self.pw_hash_version)
+ assert is_hex_str(hex_str), f"expected hex str, got {type(hex_str)} with {len(hex_str)=}"
+ return hex_str
+
+ def dump(self):
+ d = Deterministic_KeyStore.dump(self)
+ d['mpk'] = self.mpk
+ return d
+
+ def add_seed(self, seed):
+ Deterministic_KeyStore.add_seed(self, seed)
+ hex_seed = self._get_hex_seed(None)
+ self.mpk = self.mpk_from_seed(hex_seed)
+
+ def add_master_public_key(self, mpk: str) -> None:
+ self.mpk = mpk
+
+ def format_seed(self, seed):
+ """Returns seed in hex format.
+
+ seed: either in hex or as mnemonic words
+ """
+ from . import old_mnemonic, mnemonic
+ seed = mnemonic.normalize_text(seed)
+ # see if seed was entered as hex
+ if seed:
+ try:
+ bfh(seed)
+ return str(seed)
+ except Exception:
+ pass
+ words = seed.split()
+ seed = old_mnemonic.mn_decode(words)
+ if not seed:
+ raise Exception("Invalid seed")
+ return seed
+
+ def get_seed(self, password):
+ from . import old_mnemonic
+ hex_seed = self._get_hex_seed(password)
+ return ' '.join(old_mnemonic.mn_encode(hex_seed))
+
+ @classmethod
+ def mpk_from_seed(cls, hex_seed: str) -> str:
+ secexp = cls.stretch_key(hex_seed)
+ privkey = ecc.ECPrivkey.from_secret_scalar(secexp)
+ return privkey.get_public_key_hex(compressed=False)[2:]
+
+ @classmethod
+ def stretch_key(cls, hex_seed: str) -> int:
+ assert is_hex_str(hex_seed), f"expected hex str, got {type(hex_seed)} with {len(hex_seed)=}"
+ encoded_hex_seed = hex_seed.encode('ascii')
+ x = encoded_hex_seed
+ for i in range(100000):
+ x = hashlib.sha256(x + encoded_hex_seed).digest()
+ return string_to_number(x)
+
+ @classmethod
+ def get_sequence(cls, mpk: str, for_change: int, n: int) -> int:
+ return string_to_number(sha256d(("%d:%d:"%(n, for_change)).encode('ascii') + bfh(mpk)))
+
+ @classmethod
+ def get_pubkey_from_mpk(cls, mpk: str, for_change: int, n: int) -> bytes:
+ z = cls.get_sequence(mpk, for_change, n)
+ master_public_key = ecc.ECPubkey(bfh('04'+mpk))
+ public_key = master_public_key + z*ecc.GENERATOR
+ return public_key.get_public_key_bytes(compressed=False)
+
+ def _derive_pubkey(self, for_change, n) -> bytes:
+ for_change = int(for_change)
+ if for_change not in (0, 1):
+ raise CannotDerivePubkey("forbidden path")
+ return self.get_pubkey_from_mpk(self.mpk, for_change, n)
+
+ def _get_private_key_from_stretched_exponent(self, for_change: int, n: int, secexp: int) -> bytes:
+ secexp = (secexp + self.get_sequence(self.mpk, for_change, n)) % ecc.CURVE_ORDER
+ pk = int.to_bytes(secexp, length=32, byteorder='big', signed=False)
+ return pk
+
+ def get_private_key(self, sequence: Sequence[int], password):
+ hex_seed = self._get_hex_seed(password)
+ secexp = self.stretch_key(hex_seed)
+ self._check_seed(hex_seed, secexp=secexp)
+ for_change, n = sequence
+ assert isinstance(for_change, int), type(for_change)
+ assert isinstance(n, int), type(n)
+ pk = self._get_private_key_from_stretched_exponent(for_change, n, secexp)
+ return pk, False
+
+ def _check_seed(self, hex_seed: str, *, secexp: int = None) -> None:
+ if secexp is None:
+ secexp = self.stretch_key(hex_seed)
+ master_private_key = ecc.ECPrivkey.from_secret_scalar(secexp)
+ master_public_key = master_private_key.get_public_key_bytes(compressed=False)[1:]
+ if master_public_key != bfh(self.mpk):
+ raise InvalidPassword()
+
+ @also_test_none_password
+ def check_password(self, password):
+ hex_seed = self._get_hex_seed(password)
+ self._check_seed(hex_seed)
+
+ def get_master_public_key(self):
+ return self.mpk
+
+ def get_derivation_prefix(self) -> str:
+ return 'm'
+
+ def get_root_fingerprint(self) -> str:
+ if self._root_fingerprint is None:
+ master_public_key = ecc.ECPubkey(bfh('04'+self.mpk))
+ xfp = hash_160(master_public_key.get_public_key_bytes(compressed=True))[0:4]
+ self._root_fingerprint = xfp.hex().lower()
+ return self._root_fingerprint
+
+ def get_fp_and_derivation_to_be_used_in_partial_tx(
+ self,
+ der_suffix: Sequence[int],
+ *,
+ only_der_suffix: bool,
+ ) -> Tuple[bytes, Sequence[int]]:
+ fingerprint_hex = self.get_root_fingerprint()
+ der_prefix_str = self.get_derivation_prefix()
+ fingerprint_bytes = bfh(fingerprint_hex)
+ der_prefix_ints = convert_bip32_strpath_to_intpath(der_prefix_str)
+ der_full = der_prefix_ints + list(der_suffix)
+ return fingerprint_bytes, der_full
+
+ def get_pubkey_provider(self, sequence: 'AddressIndexGeneric') -> Optional[PubkeyProvider]:
+ return PubkeyProvider(
+ origin=None,
+ pubkey=self.derive_pubkey(*sequence).hex(),
+ deriv_path=None,
+ )
+
+ def update_password(self, old_password, new_password):
+ self.check_password(old_password)
+ if new_password == '':
+ new_password = None
+ if self.has_seed():
+ decoded = pw_decode(self.seed, old_password, version=self.pw_hash_version)
+ self.seed = pw_encode(decoded, new_password, version=PW_HASH_VERSION_LATEST)
+ self.pw_hash_version = PW_HASH_VERSION_LATEST
+
+
+class Hardware_KeyStore(Xpub, KeyStore):
+ hw_type: str
+ device: str
+ plugin: 'HW_PluginBase'
+ thread: Optional['TaskThread'] = None
+
+ type = 'hardware'
+
+ def __init__(self, d):
+ Xpub.__init__(self, derivation_prefix=d.get('derivation'), root_fingerprint=d.get('root_fingerprint'))
+ KeyStore.__init__(self)
+ # Errors and other user interaction is done through the wallet's
+ # handler. The handler is per-window and preserved across
+ # device reconnects
+ self.xpub = d.get('xpub')
+ self.label = d.get('label') # type: Optional[str]
+ self.soft_device_id = d.get('soft_device_id') # type: Optional[str]
+ self.handler = None # type: Optional[HardwareHandlerBase]
+ run_hook('init_keystore', self)
+
+ def watching_only_keystore(self):
+ return BIP32_KeyStore({
+ 'xpub': self.xpub,
+ 'root_fingerprint': self.get_root_fingerprint(),
+ 'derivation': self.get_derivation_prefix(),
+ })
+
+ def set_label(self, label: Optional[str]) -> None:
+ self.label = label
+
+ def may_have_password(self):
+ return False
+
+ def is_deterministic(self):
+ return True
+
+ def get_type_text(self) -> str:
+ return f'hw[{self.hw_type}]'
+
+ def dump(self):
+ return {
+ 'type': self.type,
+ 'hw_type': self.hw_type,
+ 'xpub': self.xpub,
+ 'derivation': self.get_derivation_prefix(),
+ 'root_fingerprint': self.get_root_fingerprint(),
+ 'label': self.label,
+ 'soft_device_id': self.soft_device_id,
+ }
+
+ def is_watching_only(self):
+ """The wallet is not watching-only; the user will be prompted for
+ pin and passphrase as appropriate when needed."""
+ assert not self.has_seed()
+ return False
+
+ def get_client(
+ self,
+ force_pair: bool = True,
+ *,
+ devices: Sequence['Device'] = None,
+ allow_user_interaction: bool = True,
+ ) -> Optional['HardwareClientBase']:
+ return self.plugin.get_client(
+ self,
+ force_pair=force_pair,
+ devices=devices,
+ allow_user_interaction=allow_user_interaction,
+ )
+
+ def get_password_for_storage_encryption(self) -> str:
+ client = self.get_client()
+ return client.get_password_for_storage_encryption()
+
+ def has_usable_connection_with_device(self) -> bool:
+ # we try to create a client even if there isn't one already,
+ # but do not prompt the user if auto-select fails:
+ client = self.get_client(
+ force_pair=True,
+ allow_user_interaction=False,
+ )
+ if client is None:
+ return False
+ return client.has_usable_connection_with_device()
+
+ def ready_to_sign(self):
+ return super().ready_to_sign() and self.has_usable_connection_with_device()
+
+ def opportunistically_fill_in_missing_info_from_device(self, client: 'HardwareClientBase'):
+ assert client is not None
+ if self._root_fingerprint is None:
+ self._root_fingerprint = client.request_root_fingerprint_from_device()
+ self.is_requesting_to_be_rewritten_to_wallet_file = True
+ if self.label != client.label():
+ self.label = client.label()
+ self.is_requesting_to_be_rewritten_to_wallet_file = True
+ if self.soft_device_id != client.get_soft_device_id():
+ self.soft_device_id = client.get_soft_device_id()
+ self.is_requesting_to_be_rewritten_to_wallet_file = True
+
+ def pairing_code(self) -> Optional[str]:
+ """Used by the DeviceMgr to keep track of paired hw devices."""
+ if not self.soft_device_id:
+ return None
+ return f"{self.plugin.name}/{self.soft_device_id}"
+
+
+KeyStoreWithMPK = Union[KeyStore, MasterPublicKeyMixin] # intersection really...
+AddressIndexGeneric = Union[Sequence[int], str] # can be hex pubkey str
+
+
+def bip39_normalize_passphrase(passphrase: str):
+ return normalize('NFKD', passphrase or '')
+
+
+def bip39_to_seed(mnemonic: str, *, passphrase: Optional[str]) -> bytes:
+ import hashlib
+ passphrase = passphrase or ""
+ PBKDF2_ROUNDS = 2048
+ mnemonic = normalize('NFKD', ' '.join(mnemonic.split()))
+ passphrase = bip39_normalize_passphrase(passphrase)
+ return hashlib.pbkdf2_hmac('sha512', mnemonic.encode('utf-8'),
+ b'mnemonic' + passphrase.encode('utf-8'), iterations = PBKDF2_ROUNDS)
+
+
+def bip39_is_checksum_valid(
+ mnemonic: str,
+ *,
+ wordlist: Wordlist = None,
+) -> Tuple[bool, bool]:
+ """Test checksum of bip39 mnemonic assuming English wordlist.
+ Returns tuple (is_checksum_valid, is_wordlist_valid)
+ """
+ words = [normalize('NFKD', word) for word in mnemonic.split()]
+ words_len = len(words)
+ if wordlist is None:
+ wordlist = Wordlist.from_file("english.txt")
+ n = len(wordlist)
+ i = 0
+ words.reverse()
+ while words:
+ w = words.pop()
+ try:
+ k = wordlist.index(w)
+ except ValueError:
+ return False, False
+ i = i*n + k
+ if words_len not in [12, 15, 18, 21, 24]:
+ return False, True
+ checksum_length = 11 * words_len // 33 # num bits
+ entropy_length = 32 * checksum_length # num bits
+ entropy = i >> checksum_length
+ checksum = i % 2**checksum_length
+ entropy_bytes = int.to_bytes(entropy, length=entropy_length//8, byteorder="big")
+ hashed = int.from_bytes(sha256(entropy_bytes), byteorder="big")
+ calculated_checksum = hashed >> (256 - checksum_length)
+ return checksum == calculated_checksum, True
+
+
+def from_bip43_rootseed(
+ root_seed: bytes,
+ *,
+ derivation: str,
+ xtype: Optional[str] = None,
+):
+ k = BIP32_KeyStore({})
+ if xtype is None:
+ xtype = xtype_from_derivation(derivation)
+ k.add_xprv_from_seed(root_seed, xtype=xtype, derivation=derivation)
+ return k
+
+
+PURPOSE48_SCRIPT_TYPES = {
+ 'p2wsh-p2sh': 1, # specifically multisig
+ 'p2wsh': 2, # specifically multisig
+}
+PURPOSE48_SCRIPT_TYPES_INV = inv_dict(PURPOSE48_SCRIPT_TYPES)
+
+
+def xtype_from_derivation(derivation: str) -> str:
+ """Returns the script type to be used for this derivation."""
+ bip32_indices = convert_bip32_strpath_to_intpath(derivation)
+ if len(bip32_indices) >= 1:
+ if bip32_indices[0] == 84 + BIP32_PRIME:
+ return 'p2wpkh'
+ elif bip32_indices[0] == 49 + BIP32_PRIME:
+ return 'p2wpkh-p2sh'
+ elif bip32_indices[0] == 44 + BIP32_PRIME:
+ return 'standard'
+ elif bip32_indices[0] == 45 + BIP32_PRIME:
+ return 'standard'
+
+ if len(bip32_indices) >= 4:
+ if bip32_indices[0] == 48 + BIP32_PRIME:
+ # m / purpose' / coin_type' / account' / script_type' / change / address_index
+ script_type_int = bip32_indices[3] - BIP32_PRIME
+ script_type = PURPOSE48_SCRIPT_TYPES_INV.get(script_type_int)
+ if script_type is not None:
+ return script_type
+ return 'standard'
+
+
+hw_keystores = {} # type: Dict[str, Type[Hardware_KeyStore]]
+
+def register_keystore(hw_type: str, constructor: Type[Hardware_KeyStore]) -> None:
+ hw_keystores[hw_type] = constructor
+
+def hardware_keystore(d) -> Hardware_KeyStore:
+ hw_type = d['hw_type']
+ if hw_type in hw_keystores:
+ constructor = hw_keystores[hw_type]
+ return constructor(d)
+ raise WalletFileException(f'unknown hardware type: {hw_type}. '
+ f'hw_keystores: {list(hw_keystores)}')
+
+def load_keystore(db: 'WalletDB', name: str) -> KeyStore:
+ # deepcopy object to avoid keeping a pointer to db.data
+ # note: this is needed as type(wallet.db.get("keystore")) != StoredDict
+ d = copy.deepcopy(db.get(name, {}))
+ t = d.get('type')
+ if not t:
+ raise WalletFileException(
+ 'Wallet format requires update.\n'
+ 'Cannot find keystore for name {}'.format(name))
+ keystore_constructors = {ks.type: ks for ks in [Old_KeyStore, Imported_KeyStore, BIP32_KeyStore]}
+ keystore_constructors['hardware'] = hardware_keystore
+ try:
+ ks_constructor = keystore_constructors[t]
+ except KeyError:
+ raise WalletFileException(f'Unknown type {t} for keystore named {name}')
+ k = ks_constructor(d)
+ return k
+
+
+def is_old_mpk(mpk: str) -> bool:
+ try:
+ int(mpk, 16) # test if hex string
+ except Exception:
+ return False
+ if len(mpk) != 128:
+ return False
+ try:
+ ecc.ECPubkey(bfh('04' + mpk))
+ except Exception:
+ return False
+ return True
+
+
+def is_address_list(text: str) -> bool:
+ parts = text.split()
+ return bool(parts) and all(bitcoin.is_address(x) for x in parts)
+
+
+def get_private_keys(text: str, *, allow_spaces_inside_key=True, raise_on_error=False) -> Sequence[str]:
+ if allow_spaces_inside_key: # see #1612
+ parts = text.split('\n')
+ parts = map(lambda x: ''.join(x.split()), parts)
+ parts = list(filter(bool, parts))
+ else:
+ parts = text.split()
+ if bool(parts) and all(bitcoin.is_private_key(x, raise_on_error=raise_on_error) for x in parts):
+ return parts
+ return []
+
+
+def is_private_key_list(text: str, *, allow_spaces_inside_key: bool = True, raise_on_error: bool = False) -> bool:
+ return bool(get_private_keys(text,
+ allow_spaces_inside_key=allow_spaces_inside_key,
+ raise_on_error=raise_on_error))
+
+
+def is_master_key(x: str) -> bool:
+ return is_old_mpk(x) or is_bip32_key(x)
+
+
+def is_bip32_key(x: str) -> bool:
+ return is_xprv(x) or is_xpub(x)
+
+
+def bip44_derivation(account_id: int, bip43_purpose: int = 44) -> str:
+ coin = constants.net.BIP44_COIN_TYPE
+ der = "m/%d'/%d'/%d'" % (bip43_purpose, coin, int(account_id))
+ return normalize_bip32_derivation(der)
+
+
+def purpose48_derivation(account_id: int, xtype: str) -> str:
+ # m / purpose' / coin_type' / account' / script_type' / change / address_index
+ bip43_purpose = 48
+ coin = constants.net.BIP44_COIN_TYPE
+ account_id = int(account_id)
+ script_type_int = PURPOSE48_SCRIPT_TYPES.get(xtype)
+ if script_type_int is None:
+ raise Exception('unknown xtype: {}'.format(xtype))
+ der = "m/%d'/%d'/%d'/%d'" % (bip43_purpose, coin, account_id, script_type_int)
+ return normalize_bip32_derivation(der)
+
+
+def from_seed(seed: str, *, passphrase: Optional[str], for_multisig: bool = False) -> Union[BIP32_KeyStore, Old_KeyStore]:
+ passphrase = passphrase or ""
+ t = calc_seed_type(seed)
+ if t == 'old':
+ if passphrase:
+ raise Exception("'old'-type electrum seed cannot have passphrase")
+ keystore = Old_KeyStore({})
+ keystore.add_seed(seed)
+ elif t in ['standard', 'segwit']:
+ keystore = BIP32_KeyStore({})
+ keystore.add_seed(seed)
+ keystore.passphrase = passphrase
+ bip32_seed = Mnemonic.mnemonic_to_seed(seed, passphrase=passphrase)
+ if t == 'standard':
+ der = "m/"
+ xtype = 'standard'
+ else:
+ der = "m/1'/" if for_multisig else "m/0'/"
+ xtype = 'p2wsh' if for_multisig else 'p2wpkh'
+ keystore.add_xprv_from_seed(bip32_seed, xtype=xtype, derivation=der)
+ else:
+ raise BitcoinException('Unexpected seed type {}'.format(repr(t)))
+ return keystore
+
+def from_private_key_list(text: str) -> Imported_KeyStore:
+ keystore = Imported_KeyStore({})
+ for x in get_private_keys(text):
+ keystore.import_privkey(x, None)
+ return keystore
+
+def from_old_mpk(mpk: str) -> Old_KeyStore:
+ keystore = Old_KeyStore({})
+ keystore.add_master_public_key(mpk)
+ return keystore
+
+def from_xpub(xpub: str) -> BIP32_KeyStore:
+ k = BIP32_KeyStore({})
+ k.add_xpub(xpub)
+ return k
+
+def from_xprv(xprv: str) -> BIP32_KeyStore:
+ k = BIP32_KeyStore({})
+ k.add_xprv(xprv)
+ return k
+
+def from_master_key(text: str) -> Union[BIP32_KeyStore, Old_KeyStore]:
+ if is_xprv(text):
+ k = from_xprv(text)
+ elif is_old_mpk(text):
+ k = from_old_mpk(text)
+ elif is_xpub(text):
+ k = from_xpub(text)
+ else:
+ raise BitcoinException('Invalid master key')
+ return k
diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py
new file mode 100644
index 000000000000..ce9085c9c3f2
--- /dev/null
+++ b/electrum/lnchannel.py
@@ -0,0 +1,2016 @@
+# Copyright (C) 2018 The Electrum developers
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+import dataclasses
+import enum
+from collections import defaultdict
+from enum import IntEnum, Enum
+from typing import (
+ Optional, Dict, List, Tuple, NamedTuple,
+ Iterable, Sequence, TYPE_CHECKING, Iterator, Union, Mapping)
+from abc import ABC, abstractmethod
+import itertools
+
+from aiorpcx import NetAddress
+
+import electrum_ecc as ecc
+from electrum_ecc import ECPubkey
+
+from . import constants, util
+from .util import bfh, chunks, TxMinedInfo, error_text_bytes_to_safe_str, now
+from .bitcoin import redeem_script_to_address, COINBASE_MATURITY
+from .crypto import sha256, sha256d
+from .transaction import Transaction, PartialTransaction, TxInput, Sighash
+from .logging import Logger
+from .lntransport import LNPeerAddr
+from .lnonion import OnionRoutingFailure
+from . import lnutil
+from .lnutil import (Outpoint, LocalConfig, RemoteConfig, Keypair, OnlyPubkeyKeypair, ChannelConstraints,
+ get_per_commitment_secret_from_seed, secret_to_pubkey, derive_privkey, make_closing_tx,
+ sign_and_get_sig_string, RevocationStore, derive_blinded_pubkey, Direction, derive_pubkey,
+ make_htlc_tx_with_open_channel, make_commitment, UpdateAddHtlc,
+ funding_output_script, SENT, RECEIVED, LOCAL, REMOTE, HTLCOwner, make_commitment_outputs,
+ ScriptHtlc, PaymentFailure, calc_fees_for_commitment_tx, RemoteMisbehaving, make_htlc_output_witness_script,
+ ShortChannelID, map_htlcs_to_ctx_output_idxs,
+ fee_for_htlc_output, offered_htlc_trim_threshold_sat,
+ received_htlc_trim_threshold_sat, make_commitment_output_to_remote_address, FIXED_ANCHOR_SAT,
+ ChannelType, LNProtocolWarning, ZEROCONF_TIMEOUT)
+from .lnsweep import sweep_our_ctx, sweep_their_ctx
+from .lnsweep import sweep_their_htlctx_justice, sweep_our_htlctx, SweepInfo, MaybeSweepInfo
+from .lnsweep import sweep_their_ctx_to_remote_backup
+from .lnhtlc import HTLCManager
+from .lnmsg import encode_msg, decode_msg
+from .address_synchronizer import TX_HEIGHT_LOCAL, TX_HEIGHT_UNCONFIRMED
+from .lnutil import CHANNEL_OPENING_TIMEOUT_BLOCKS, CHANNEL_OPENING_TIMEOUT_SEC
+from .lnutil import ChannelBackupStorage, ImportedChannelBackupStorage, OnchainChannelBackupStorage
+from .lnutil import format_short_channel_id
+from .fee_policy import FEERATE_PER_KW_MIN_RELAY_LIGHTNING
+
+if TYPE_CHECKING:
+ from .lnworker import LNWallet
+ from .json_db import StoredDict
+
+
+# channel flags
+CF_ANNOUNCE_CHANNEL = 0x01
+
+# lightning channel states
+# Note: these states are persisted by name (for a given channel) in the wallet file,
+# so consider doing a wallet db upgrade when changing them.
+class ChannelState(IntEnum):
+ PREOPENING = 0 # Initial negotiation. Channel will not be reestablished
+ OPENING = 1 # Channel will be reestablished. (per BOLT2)
+ # - Funding node: has received funding_signed (can broadcast the funding tx)
+ # - Non-funding node: has sent the funding_signed message.
+ FUNDED = 2 # Funding tx was mined (requires min_depth and tx verification)
+ OPEN = 3 # both parties have sent funding_locked
+ SHUTDOWN = 4 # shutdown has been sent.
+ CLOSING = 5 # closing negotiation done. we have a fully signed tx.
+ FORCE_CLOSING = 6 # *we* force-closed, and closing tx is unconfirmed. Note that if the
+ # remote force-closes then we remain OPEN until it gets mined -
+ # the server could be lying to us with a fake tx.
+ REQUESTED_FCLOSE = 7 # Chan is open, but we have tried to request the *remote* to force-close
+ WE_ARE_TOXIC = 8 # Chan is open, but we have lost state and the remote proved this.
+ # The remote must force-close, it is *not* safe for us to do so.
+ CLOSED = 9 # closing tx has been mined
+ REDEEMED = 10 # we can stop watching
+
+
+class PeerState(IntEnum):
+ DISCONNECTED = 0
+ REESTABLISHING = 1
+ GOOD = 2
+ BAD = 3
+
+
+cs = ChannelState
+state_transitions = [
+ (cs.PREOPENING, cs.OPENING),
+ (cs.OPENING, cs.FUNDED),
+ (cs.FUNDED, cs.OPEN),
+ (cs.OPENING, cs.SHUTDOWN),
+ (cs.FUNDED, cs.SHUTDOWN),
+ (cs.OPEN, cs.SHUTDOWN),
+ (cs.SHUTDOWN, cs.SHUTDOWN), # if we reestablish
+ (cs.SHUTDOWN, cs.CLOSING),
+ (cs.CLOSING, cs.CLOSING),
+ # we can force close almost any time
+ (cs.OPENING, cs.FORCE_CLOSING),
+ (cs.FUNDED, cs.FORCE_CLOSING),
+ (cs.OPEN, cs.FORCE_CLOSING),
+ (cs.SHUTDOWN, cs.FORCE_CLOSING),
+ (cs.CLOSING, cs.FORCE_CLOSING),
+ (cs.REQUESTED_FCLOSE, cs.FORCE_CLOSING),
+ # we can request a force-close almost any time
+ (cs.OPENING, cs.REQUESTED_FCLOSE),
+ (cs.FUNDED, cs.REQUESTED_FCLOSE),
+ (cs.OPEN, cs.REQUESTED_FCLOSE),
+ (cs.SHUTDOWN, cs.REQUESTED_FCLOSE),
+ (cs.CLOSING, cs.REQUESTED_FCLOSE),
+ (cs.REQUESTED_FCLOSE, cs.REQUESTED_FCLOSE),
+ # we can get force closed almost any time
+ (cs.OPENING, cs.CLOSED),
+ (cs.FUNDED, cs.CLOSED),
+ (cs.OPEN, cs.CLOSED),
+ (cs.SHUTDOWN, cs.CLOSED),
+ (cs.CLOSING, cs.CLOSED),
+ (cs.REQUESTED_FCLOSE, cs.CLOSED),
+ (cs.WE_ARE_TOXIC, cs.CLOSED),
+ # during channel_reestablish, we might realise we have lost state
+ (cs.OPENING, cs.WE_ARE_TOXIC),
+ (cs.FUNDED, cs.WE_ARE_TOXIC),
+ (cs.OPEN, cs.WE_ARE_TOXIC),
+ (cs.SHUTDOWN, cs.WE_ARE_TOXIC),
+ (cs.REQUESTED_FCLOSE, cs.WE_ARE_TOXIC),
+ (cs.WE_ARE_TOXIC, cs.WE_ARE_TOXIC),
+ #
+ (cs.FORCE_CLOSING, cs.FORCE_CLOSING), # allow multiple attempts
+ (cs.FORCE_CLOSING, cs.CLOSED),
+ (cs.FORCE_CLOSING, cs.REDEEMED),
+ (cs.CLOSED, cs.REDEEMED),
+ (cs.OPENING, cs.REDEEMED), # channel never funded (dropped from mempool)
+ (cs.PREOPENING, cs.REDEEMED), # channel never funded
+]
+del cs # delete as name is ambiguous without context
+
+
+class ChanCloseOption(Enum):
+ COOP_CLOSE = enum.auto()
+ LOCAL_FCLOSE = enum.auto()
+ REQUEST_REMOTE_FCLOSE = enum.auto()
+
+
+class RevokeAndAck(NamedTuple):
+ per_commitment_secret: bytes
+ next_per_commitment_point: bytes
+
+
+class RemoteCtnTooFarInFuture(Exception): pass
+
+
+def htlcsum(htlcs: Iterable[UpdateAddHtlc]):
+ return sum([x.amount_msat for x in htlcs])
+
+
+class HTLCWithStatus(NamedTuple):
+ channel_id: bytes
+ htlc: UpdateAddHtlc
+ direction: Direction
+ status: str
+
+
+class AbstractChannel(Logger, ABC):
+ storage: Union['StoredDict', dict]
+ config: Dict[HTLCOwner, Union[LocalConfig, RemoteConfig]]
+ lnworker: 'LNWallet'
+ channel_id: bytes
+ short_channel_id: Optional[ShortChannelID] = None
+ funding_outpoint: Outpoint
+ node_id: bytes # note that it might not be the full 33 bytes; for OCB it is only the prefix
+ should_request_force_close: bool = False
+ _state: ChannelState
+ _who_closed: Optional[int] = None # HTLCOwner (1 or -1). 0 means "unknown"
+
+ def set_short_channel_id(self, short_id: ShortChannelID) -> None:
+ self.short_channel_id = short_id
+ self.storage["short_channel_id"] = short_id
+
+ def get_id_for_log(self) -> str:
+ scid = self.short_channel_id
+ if scid:
+ return str(scid)
+ return self.channel_id.hex()
+
+ def short_id_for_GUI(self) -> str:
+ return format_short_channel_id(self.short_channel_id)
+
+ def diagnostic_name(self):
+ return self.get_id_for_log()
+
+ def set_state(self, state: ChannelState, *, force: bool = False) -> None:
+ """Set on-chain state.
+ `force` can be set while debugging from the console to allow illegal transitions.
+ """
+ old_state = self._state
+ if not force and (old_state, state) not in state_transitions:
+ raise Exception(f"Transition not allowed: {old_state.name} -> {state.name}")
+ self.logger.debug(f'Setting channel state: {old_state.name} -> {state.name}')
+ self._state = state
+ self.storage['state'] = self._state.name
+ self.lnworker.channel_state_changed(self)
+
+ def get_state(self) -> ChannelState:
+ return self._state
+
+ def is_funded(self) -> bool:
+ # NOTE: also true for unfunded zeroconf channels (OPEN > FUNDED)
+ return self.get_state() >= ChannelState.FUNDED
+
+ def is_open(self) -> bool:
+ return self.get_state() == ChannelState.OPEN
+
+ def is_closed(self) -> bool:
+ # the closing txid has been saved
+ return self.get_state() >= ChannelState.CLOSING
+
+ def is_closed_or_closing(self):
+ # related: self.get_state_for_GUI
+ return self.is_closed() or self.unconfirmed_closing_txid is not None
+
+ def is_redeemed(self) -> bool:
+ return self.get_state() == ChannelState.REDEEMED
+
+ def need_to_subscribe(self) -> bool:
+ """Whether lnwatcher/synchronizer need to be watching this channel."""
+ if not self.is_redeemed():
+ return True
+ # Chan already deeply closed. Still, if some txs are missing, we should sub.
+ # check we have funding tx
+ # note: tx might not be directly related to the wallet, e.g. chan opened by remote
+ if (funding_item := self.get_funding_height()) is None:
+ return True
+ funding_txid, funding_height, funding_timestamp = funding_item
+ if self.lnworker.wallet.adb.get_transaction(funding_txid) is None:
+ return True
+ # check we have closing tx
+ # note: tx might not be directly related to the wallet, e.g. local-fclose
+ if (closing_item := self.get_closing_height()) is None:
+ return True
+ closing_txid, closing_height, closing_timestamp = closing_item
+ if self.lnworker.wallet.adb.get_transaction(closing_txid) is None:
+ return True
+ return False
+
+ @abstractmethod
+ def get_close_options(self) -> Sequence[ChanCloseOption]:
+ pass
+
+ def save_funding_height(self, *, txid: str, height: int, timestamp: Optional[int]) -> None:
+ self.storage['funding_height'] = txid, height, timestamp
+
+ def get_funding_height(self) -> Optional[Tuple[str, int, Optional[int]]]:
+ return self.storage.get('funding_height')
+
+ def delete_funding_height(self):
+ self.storage.pop('funding_height', None)
+
+ def save_closing_height(self, *, txid: str, height: int, timestamp: Optional[int]) -> None:
+ self.storage['closing_height'] = txid, height, timestamp
+
+ def get_closing_height(self) -> Optional[Tuple[str, int, Optional[int]]]:
+ return self.storage.get('closing_height')
+
+ def delete_closing_height(self):
+ self.storage.pop('closing_height', None)
+
+ def create_sweeptxs_for_our_ctx(self, ctx: Transaction) -> Dict[str, MaybeSweepInfo]:
+ return sweep_our_ctx(chan=self, ctx=ctx)
+
+ def create_sweeptxs_for_their_ctx(self, ctx: Transaction) -> Dict[str, MaybeSweepInfo]:
+ return sweep_their_ctx(chan=self, ctx=ctx)
+
+ def is_backup(self) -> bool:
+ return False
+
+ def get_local_scid_alias(self, *, create_new_if_needed: bool = False) -> Optional[bytes]:
+ return None
+
+ def get_remote_scid_alias(self) -> Optional[bytes]:
+ return None
+
+ def get_remote_peer_sent_error(self) -> Optional[str]:
+ return None
+
+ def get_ctx_sweep_info(self, ctx: Transaction) -> Tuple[bool, Dict[str, MaybeSweepInfo]]:
+ our_sweep_info = self.create_sweeptxs_for_our_ctx(ctx)
+ their_sweep_info = self.create_sweeptxs_for_their_ctx(ctx)
+ if our_sweep_info:
+ sweep_info = our_sweep_info
+ who_closed = LOCAL
+ elif their_sweep_info:
+ sweep_info = their_sweep_info
+ who_closed = REMOTE
+ else:
+ sweep_info = {}
+ who_closed = 0
+ if self._who_closed != who_closed: # mostly here to limit log spam
+ self._who_closed = who_closed
+ if who_closed == LOCAL:
+ self.logger.info(f'we (local) force closed')
+ elif who_closed == REMOTE:
+ self.logger.info(f'they (remote) force closed.')
+ else:
+ self.logger.info(f'not sure who closed. maybe co-op close?')
+ is_local_ctx = who_closed == LOCAL
+ return is_local_ctx, sweep_info
+
+ def maybe_sweep_htlcs(self, ctx: Transaction, htlc_tx: Transaction) -> Dict[str, MaybeSweepInfo]:
+ return {}
+
+ def extract_preimage_from_htlc_txin(self, txin: TxInput, *, is_deeply_mined: bool) -> None:
+ return
+
+ def update_onchain_state(self, *, funding_txid: str, funding_height: TxMinedInfo,
+ closing_txid: str, closing_height: TxMinedInfo, keep_watching: bool) -> None:
+ # note: state transitions are irreversible, but
+ # save_funding_height, save_closing_height are reversible
+ if funding_height.height() == TX_HEIGHT_LOCAL:
+ self.update_unfunded_state()
+ elif closing_height.height() == TX_HEIGHT_LOCAL:
+ self.update_funded_state(
+ funding_txid=funding_txid,
+ funding_height=funding_height)
+ else:
+ self.update_closed_state(
+ funding_txid=funding_txid,
+ funding_height=funding_height,
+ closing_txid=closing_txid,
+ closing_height=closing_height,
+ keep_watching=keep_watching)
+
+ def update_unfunded_state(self) -> None:
+ self.delete_funding_height()
+ self.delete_closing_height()
+ state = self.get_state()
+ if state in [ChannelState.PREOPENING, ChannelState.OPENING, ChannelState.FORCE_CLOSING]:
+ if self.is_initiator():
+ # set channel state to REDEEMED so that it can be removed manually
+ # to protect ourselves against a server lying by omission,
+ # we check that funding_inputs have been double spent and deeply mined
+ inputs = self.storage.get('funding_inputs', [])
+ if not inputs:
+ self.logger.info(f'channel funding inputs are not provided')
+ self.set_state(ChannelState.REDEEMED)
+ for i in inputs:
+ spender_txid = self.lnworker.wallet.db.get_spent_outpoint(*i)
+ if spender_txid is None:
+ continue
+ if spender_txid != self.funding_outpoint.txid:
+ tx_mined_height = self.lnworker.wallet.adb.get_tx_height(spender_txid)
+ if tx_mined_height.conf > lnutil.REDEEM_AFTER_DOUBLE_SPENT_DELAY:
+ self.logger.info(f'channel is double spent {inputs}')
+ self.set_state(ChannelState.REDEEMED)
+ break
+ elif self.has_funding_timed_out():
+ self.logger.warning(f"dropping incoming channel, funding tx not found in mempool")
+ self.lnworker.remove_channel(self.channel_id)
+ elif self.is_zeroconf() and state in [ChannelState.OPEN, ChannelState.CLOSING, ChannelState.FORCE_CLOSING]:
+ # handling zeroconf channels with no funding tx, can happen if broadcasting fails on LSP side
+ # or if the LSP did double spent the funding tx/never published it intentionally.
+ if not self.lnworker.wallet.is_up_to_date() or not self.lnworker.network \
+ or self.lnworker.network.blockchain().is_tip_stale():
+ # ensure we are up to date to prevent accidentally dropping a channel that is funded
+ return
+ chan_age = now() - self.storage['init_timestamp']
+ if chan_age > ZEROCONF_TIMEOUT:
+ # freeze the channel to avoid receiving even more into this unfunded channel.
+ # NOTE: we don't reject htlcs arriving on frozen channels, this only really
+ # stops us from including the channel in invoice routing hints.
+ if isinstance(self, Channel):
+ self.set_frozen_for_receiving(True)
+
+ # un-trust the LSP so the user doesn't accept another channel from the same provider
+ # compare the node id's as the user might already have changed to another one
+ if self.node_id == self.lnworker.trusted_zeroconf_node_id:
+ self.lnworker.config.ZEROCONF_TRUSTED_NODE = ''
+
+ if self.has_funding_timed_out():
+ self.lnworker.remove_channel(self.channel_id)
+ # remove remaining local transactions from the wallet, this will also remove child transactions (closing tx)
+ # self.lnworker.lnwatcher.adb.remove_transaction(self.funding_outpoint.txid)
+ if (local_balance_sat := int(self.balance(LOCAL) // 1000)) > 0:
+ self.logger.warning(
+ f"we may have been scammed out of {local_balance_sat} sat by our "
+ f"JIT provider: {self.lnworker.config.ZEROCONF_TRUSTED_NODE} or he didn't use our preimage")
+
+ def update_funded_state(self, *, funding_txid: str, funding_height: TxMinedInfo) -> None:
+ self.save_funding_height(txid=funding_txid, height=funding_height.height(), timestamp=funding_height.timestamp)
+ self.delete_closing_height()
+ if funding_height.conf>0:
+ self.set_short_channel_id(ShortChannelID.from_components(
+ funding_height.height(), funding_height.txpos, self.funding_outpoint.output_index))
+ elif self.has_funding_timed_out():
+ self.logger.warning("dropping incoming channel, funding tx took too long to confirm")
+ self.lnworker.remove_channel(self.channel_id)
+ return
+ if self.get_state() == ChannelState.OPENING:
+ if self.is_funding_tx_mined(funding_height):
+ self.set_state(ChannelState.FUNDED)
+ elif self.is_zeroconf() and funding_height.conf >= 3 and not self.should_request_force_close:
+ if not self.is_funding_tx_mined(funding_height):
+ # funding tx is invalid (invalid amount or address) we need to get rid of the channel again
+ self.should_request_force_close = True
+ if peer := self.lnworker.lnpeermgr.get_peer_by_pubkey(self.node_id):
+ # reconnect to trigger force close request
+ peer.close_and_cleanup()
+ else:
+ # remove zeroconf flag as we are now confirmed, this is to prevent an electrum server causing
+ # us to remove a channel later in update_unfunded_state by omitting its funding tx
+ self.remove_zeroconf_flag()
+ # unfreeze in case it was frozen in update_unfunded_state
+ if isinstance(self, Channel):
+ self.set_frozen_for_receiving(False)
+
+ def update_closed_state(self, *, funding_txid: str, funding_height: TxMinedInfo,
+ closing_txid: str, closing_height: TxMinedInfo, keep_watching: bool) -> None:
+ self.save_funding_height(txid=funding_txid, height=funding_height.height(), timestamp=funding_height.timestamp)
+ self.save_closing_height(txid=closing_txid, height=closing_height.height(), timestamp=closing_height.timestamp)
+ if funding_height.conf>0:
+ self.set_short_channel_id(ShortChannelID.from_components(
+ funding_height.height(), funding_height.txpos, self.funding_outpoint.output_index))
+ if self.get_state() < ChannelState.CLOSED:
+ conf = closing_height.conf
+ if conf > 0:
+ self.set_state(ChannelState.CLOSED)
+ self.lnworker.wallet.txbatcher.set_password_future(None)
+ else:
+ # we must not trust the server with unconfirmed transactions,
+ # because the state transition is irreversible. if the remote
+ # force closed, we remain OPEN until the closing tx is confirmed
+ self.unconfirmed_closing_txid = closing_txid
+ util.trigger_callback('channel', self.lnworker.wallet, self)
+
+ if self.get_state() == ChannelState.CLOSED and not keep_watching:
+ self.set_state(ChannelState.REDEEMED)
+ if self.is_backup():
+ # auto-remove redeemed backups
+ self.lnworker.remove_channel_backup(self.channel_id)
+
+ @abstractmethod
+ def is_initiator(self) -> bool:
+ pass
+
+ @abstractmethod
+ def is_public(self) -> bool:
+ pass
+
+ @abstractmethod
+ def is_zeroconf(self) -> bool:
+ pass
+
+ @abstractmethod
+ def remove_zeroconf_flag(self) -> None:
+ pass
+
+ @abstractmethod
+ def is_funding_tx_mined(self, funding_height: TxMinedInfo) -> bool:
+ pass
+
+ @abstractmethod
+ def get_funding_address(self) -> str:
+ pass
+
+ def get_funding_tx(self) -> Optional[Transaction]:
+ funding_txid = self.funding_outpoint.txid
+ return self.lnworker.lnwatcher.adb.get_transaction(funding_txid)
+
+ @abstractmethod
+ def get_sweep_address(self) -> str:
+ """Returns a wallet address we can use to sweep coins to.
+ It could be something static to the channel (fixed for its lifecycle),
+ or it might just ask the wallet now for an unused address.
+ """
+ pass
+
+ def get_state_for_GUI(self) -> str:
+ cs = self.get_state()
+ if cs <= ChannelState.OPEN and self.unconfirmed_closing_txid:
+ return 'FORCE-CLOSING'
+ return cs.name
+
+ @abstractmethod
+ def get_oldest_unrevoked_ctn(self, subject: HTLCOwner) -> int:
+ pass
+
+ @abstractmethod
+ def included_htlcs(self, subject: HTLCOwner, direction: Direction, ctn: int = None) -> Sequence[UpdateAddHtlc]:
+ pass
+
+ @abstractmethod
+ def funding_txn_minimum_depth(self) -> int:
+ pass
+
+ @abstractmethod
+ def balance(self, whose: HTLCOwner, *, ctx_owner=HTLCOwner.LOCAL, ctn: int = None) -> int:
+ """This balance (in msat) only considers HTLCs that have been settled by ctn.
+ It disregards reserve, fees, and pending HTLCs (in both directions).
+ """
+ pass
+
+ @abstractmethod
+ def balance_minus_outgoing_htlcs(self, whose: HTLCOwner, *,
+ ctx_owner: HTLCOwner = HTLCOwner.LOCAL,
+ ctn: int = None) -> int:
+ """This balance (in msat), which includes the value of
+ pending outgoing HTLCs, is used in the UI.
+ """
+ pass
+
+ @abstractmethod
+ def is_frozen_for_sending(self) -> bool:
+ """Whether the user has marked this channel as frozen for sending.
+ Frozen channels are not supposed to be used for new outgoing payments.
+ (note that payment-forwarding ignores this option)
+ """
+ pass
+
+ @abstractmethod
+ def is_frozen_for_receiving(self) -> bool:
+ """Whether the user has marked this channel as frozen for receiving.
+ Frozen channels are not supposed to be used for new incoming payments.
+ (note that payment-forwarding ignores this option)
+ """
+ pass
+
+ @abstractmethod
+ def get_local_pubkey(self) -> bytes:
+ """Returns our node ID."""
+ pass
+
+ @abstractmethod
+ def get_capacity(self) -> Optional[int]:
+ """Returns channel capacity in satoshis, or None if unknown."""
+ pass
+
+ @abstractmethod
+ def can_be_deleted(self) -> bool:
+ pass
+
+ @abstractmethod
+ def has_funding_timed_out(self) -> bool:
+ pass
+
+ @abstractmethod
+ def get_wallet_addresses_channel_might_want_reserved(self) -> Sequence[str]:
+ """Returns a list of addrs that the wallet should not use, to avoid address-reuse.
+ Typically, these addresses are wallet.is_mine, but that is not guaranteed,
+ in which case the wallet can just ignore those.
+ """
+ pass
+
+ def has_anchors(self) -> bool:
+ pass
+
+
+class ChannelBackup(AbstractChannel):
+ """
+ current capabilities:
+ - detect force close
+ - request force close
+ - sweep my ctx to_local
+ future:
+ - will need to sweep their ctx to_remote
+ """
+
+ def __init__(self, cb: ChannelBackupStorage, *, lnworker: 'LNWallet'):
+ self.name = None
+ self.cb = cb
+ self.is_imported = isinstance(self.cb, ImportedChannelBackupStorage)
+ self.storage = {} # dummy storage
+ self._state = ChannelState.OPENING
+ self.node_id = cb.node_id if self.is_imported else cb.node_id_prefix
+ self.channel_id = cb.channel_id()
+ self.funding_outpoint = cb.funding_outpoint()
+ self.lnworker = lnworker
+ self.short_channel_id = None
+ Logger.__init__(self)
+ self.config = {}
+ if self.is_imported:
+ assert isinstance(cb, ImportedChannelBackupStorage)
+ self.init_config(cb)
+ self.unconfirmed_closing_txid = None # not a state, only for GUI
+
+ def init_config(self, cb: ImportedChannelBackupStorage):
+ local_payment_pubkey = cb.local_payment_pubkey
+ if local_payment_pubkey is None:
+ self.logger.warning(
+ f"local_payment_pubkey missing from (old-type) channel backup. "
+ f"You should export and re-import a newer backup.")
+ multisig_funding_keypair = None
+ if multisig_funding_secret := cb.multisig_funding_privkey:
+ multisig_funding_keypair = Keypair(
+ privkey=multisig_funding_secret,
+ pubkey=ecc.ECPrivkey(multisig_funding_secret).get_public_key_bytes(),
+ )
+ self.config[LOCAL] = LocalConfig.from_seed(
+ channel_seed=cb.channel_seed,
+ to_self_delay=cb.local_delay,
+ # there are three cases of backups:
+ # 1. legacy: payment_basepoint will be derived
+ # 2. static_remotekey: to_remote sweep not necessary due to wallet address
+ # 3. anchor outputs: sweep to_remote by deriving the key from the funding pubkeys
+ static_remotekey=local_payment_pubkey,
+ multisig_key=multisig_funding_keypair,
+ # dummy values
+ static_payment_key=None,
+ dust_limit_sat=None,
+ max_htlc_value_in_flight_msat=None,
+ max_accepted_htlcs=None,
+ initial_msat=None,
+ reserve_sat=None,
+ funding_locked_received=False,
+ current_commitment_signature=None,
+ current_htlc_signatures=b'',
+ htlc_minimum_msat=1,
+ upfront_shutdown_script='',
+ announcement_node_sig=b'',
+ announcement_bitcoin_sig=b'',
+ )
+ self.config[REMOTE] = RemoteConfig(
+ # payment_basepoint needed to deobfuscate ctn in our_ctx
+ payment_basepoint=OnlyPubkeyKeypair(cb.remote_payment_pubkey),
+ # revocation_basepoint is used to claim to_local in our ctx
+ revocation_basepoint=OnlyPubkeyKeypair(cb.remote_revocation_pubkey),
+ to_self_delay=cb.remote_delay,
+ # dummy values
+ multisig_key=OnlyPubkeyKeypair(None),
+ htlc_basepoint=OnlyPubkeyKeypair(None),
+ delayed_basepoint=OnlyPubkeyKeypair(None),
+ dust_limit_sat=None,
+ max_htlc_value_in_flight_msat=None,
+ max_accepted_htlcs=None,
+ initial_msat = None,
+ reserve_sat = None,
+ htlc_minimum_msat=None,
+ next_per_commitment_point=None,
+ current_per_commitment_point=None,
+ upfront_shutdown_script='',
+ announcement_node_sig=b'',
+ announcement_bitcoin_sig=b'',
+ )
+
+ def can_be_deleted(self):
+ return self.is_imported or self.is_redeemed()
+
+ def has_funding_timed_out(self):
+ return False
+
+ def get_capacity(self):
+ lnwatcher = self.lnworker.lnwatcher
+ if lnwatcher:
+ # fixme: we should probably not call that method here
+ return lnwatcher.adb.get_tx_delta(self.funding_outpoint.txid, self.cb.funding_address)
+ return None
+
+ def is_backup(self):
+ return True
+
+ def create_sweeptxs_for_their_ctx(self, ctx):
+ funding_tx = self.get_funding_tx()
+ assert funding_tx
+ return sweep_their_ctx_to_remote_backup(chan=self, ctx=ctx, funding_tx=funding_tx)
+
+ def create_sweeptxs_for_our_ctx(self, ctx):
+ if self.is_imported:
+ return sweep_our_ctx(chan=self, ctx=ctx)
+ else:
+ return {}
+
+ def maybe_sweep_htlcs(self, ctx: Transaction, htlc_tx: Transaction) -> Dict[str, MaybeSweepInfo]:
+ return {}
+
+ def extract_preimage_from_htlc_txin(self, txin: TxInput, *, is_deeply_mined: bool) -> None:
+ return None
+
+ def get_funding_address(self):
+ return self.cb.funding_address
+
+ def is_initiator(self):
+ return self.cb.is_initiator
+
+ def is_public(self):
+ return False
+
+ def get_oldest_unrevoked_ctn(self, who):
+ return -1
+
+ def included_htlcs(self, subject, direction, ctn=None):
+ return []
+
+ def funding_txn_minimum_depth(self):
+ return 1
+
+ def is_funding_tx_mined(self, funding_height):
+ return funding_height.conf > 1
+
+ def balance_minus_outgoing_htlcs(self, whose: HTLCOwner, *, ctx_owner: HTLCOwner = HTLCOwner.LOCAL, ctn: int = None):
+ return 0
+
+ def balance(self, whose: HTLCOwner, *, ctx_owner=HTLCOwner.LOCAL, ctn: int = None) -> int:
+ return 0
+
+ def is_frozen_for_sending(self) -> bool:
+ return False
+
+ def is_frozen_for_receiving(self) -> bool:
+ return False
+
+ def get_sweep_address(self) -> str:
+ return self.lnworker.wallet.get_new_sweep_address_for_channel()
+
+ def has_anchors(self) -> Optional[bool]:
+ return None
+
+ def is_zeroconf(self) -> bool:
+ return False
+
+ def remove_zeroconf_flag(self) -> None:
+ pass
+
+ def get_local_pubkey(self) -> bytes:
+ cb = self.cb
+ assert isinstance(cb, ChannelBackupStorage)
+ if isinstance(cb, ImportedChannelBackupStorage):
+ return ecc.ECPrivkey(cb.privkey).get_public_key_bytes(compressed=True)
+ if isinstance(cb, OnchainChannelBackupStorage):
+ return self.lnworker.node_keypair.pubkey
+ raise NotImplementedError(f"unexpected cb type: {type(cb)}")
+
+ def get_close_options(self) -> Sequence[ChanCloseOption]:
+ ret = []
+ if self.get_state() == ChannelState.FUNDED:
+ ret.append(ChanCloseOption.REQUEST_REMOTE_FCLOSE)
+ return ret
+
+ def get_wallet_addresses_channel_might_want_reserved(self) -> Sequence[str]:
+ if self.is_imported:
+ # For v1 imported cbs, we have the local_payment_pubkey, which is
+ # directly used as p2wpkh() of static_remotekey channels.
+ # (for v0 imported cbs, the correct local_payment_pubkey is missing, and so
+ # we might calculate a different address here, which might not be wallet.is_mine,
+ # but that should be harmless)
+ our_payment_pubkey = self.config[LOCAL].payment_basepoint.pubkey
+ to_remote_address = make_commitment_output_to_remote_address(our_payment_pubkey, has_anchors=self.has_anchors())
+ return [to_remote_address]
+ else: # on-chain backup
+ return []
+
+
+class Channel(AbstractChannel):
+ # note: try to avoid naming ctns/ctxs/etc as "current" and "pending".
+ # they are ambiguous. Use "oldest_unrevoked" or "latest" or "next".
+ # TODO enforce this ^
+
+ # our forwarding parameters for forwarding HTLCs through this channel
+ forwarding_cltv_delta = 144
+ forwarding_fee_base_msat = 1000
+ forwarding_fee_proportional_millionths = 1
+
+ def __repr__(self):
+ return "Channel(%s)"%self.get_id_for_log()
+
+ def __init__(
+ self,
+ state: 'StoredDict', *,
+ name=None,
+ lnworker: 'LNWallet',
+ initial_feerate=None,
+ jit_opening_fee: Optional[int] = None,
+ ):
+ self.jit_opening_fee = jit_opening_fee
+ self.name = name
+ self.channel_id = bfh(state["channel_id"])
+ self.short_channel_id = ShortChannelID.normalize(state["short_channel_id"])
+ Logger.__init__(self) # should be after short_channel_id is set
+ self.lnworker = lnworker
+ self.storage = state
+ self.db_lock = self.storage.lock
+ self.config = {}
+ self.config[LOCAL] = state["local_config"]
+ self.config[REMOTE] = state["remote_config"]
+ self.constraints = state["constraints"] # type: ChannelConstraints
+ self.funding_outpoint = state["funding_outpoint"]
+ self.node_id = bfh(state["node_id"])
+ self.onion_keys = state['onion_keys'] # type: Dict[int, bytes]
+ self.data_loss_protect_remote_pcp = state['data_loss_protect_remote_pcp']
+ self.hm = HTLCManager(log=state['log'], initiator = LOCAL if self.constraints.is_initiator else REMOTE, initial_feerate=initial_feerate)
+ self.unfulfilled_htlcs = state["unfulfilled_htlcs"] # type: Dict[int, Optional[str]]
+ # ^ htlc_id -> onion_packet_hex
+ self._state = ChannelState[state['state']]
+ self.peer_state = PeerState.DISCONNECTED
+ self._outgoing_channel_update = None # type: Optional[bytes]
+ self.revocation_store = RevocationStore(state["revocation_store"])
+ self._can_send_ctx_updates = True # type: bool
+ self._receive_fail_reasons = {} # type: Dict[int, tuple[bytes | None, OnionRoutingFailure | None]]
+ self.unconfirmed_closing_txid = None # not a state, only for GUI
+ self.sent_channel_ready = False # no need to persist this, because channel_ready is re-sent in channel_reestablish
+ self.sent_announcement_signatures = False
+ self.htlc_settle_time = {}
+
+ def get_local_scid_alias(self, *, create_new_if_needed: bool = False) -> Optional[bytes]:
+ """Get scid_alias to be used for *outgoing* HTLCs.
+ (called local as we choose the value)
+ """
+ if alias := self.storage.get('local_scid_alias'):
+ return bytes.fromhex(alias)
+ elif create_new_if_needed:
+ # deterministic, same secrecy level as wallet master pubkey
+ wallet_fingerprint = bytes(self.lnworker.wallet.get_fingerprint(), "utf8")
+ alias = sha256(wallet_fingerprint + self.channel_id)[0:8]
+ self.storage['local_scid_alias'] = alias.hex()
+ return alias
+ return None
+
+ def save_remote_scid_alias(self, alias: bytes):
+ self.storage['alias'] = alias.hex()
+
+ def get_remote_scid_alias(self) -> Optional[bytes]:
+ """Get scid_alias to be used for *incoming* HTLCs.
+ (called remote as the remote chooses the value)
+ """
+ alias = self.storage.get('alias')
+ return bytes.fromhex(alias) if alias else None
+
+ def get_scid_or_local_alias(self):
+ return self.short_channel_id or self.get_local_scid_alias()
+
+ def has_onchain_backup(self):
+ return self.storage.get('has_onchain_backup', False)
+
+ def can_be_deleted(self) -> bool:
+ if self.has_funding_timed_out():
+ return True
+ return self.is_redeemed()
+
+ def has_funding_timed_out(self):
+ funding_height = self.get_funding_height()
+ if self.is_initiator() or funding_height and funding_height[1] > TX_HEIGHT_UNCONFIRMED:
+ return False
+ if self.lnworker.network.blockchain().is_tip_stale() or not self.lnworker.wallet.is_up_to_date():
+ return False
+ init_height = self.storage.get('init_height', 0)
+ init_timestamp = self.storage.get('init_timestamp', 0)
+ age_blocks = self.lnworker.network.get_local_height() - init_height
+ age_sec = now() - init_timestamp
+ # some channels might not have init_height set so we check both time and block based timeouts
+ return age_blocks > CHANNEL_OPENING_TIMEOUT_BLOCKS and age_sec > CHANNEL_OPENING_TIMEOUT_SEC
+
+ def get_capacity(self):
+ return self.constraints.capacity
+
+ def is_public(self):
+ return bool(self.constraints.flags & CF_ANNOUNCE_CHANNEL)
+
+ def is_initiator(self):
+ return self.constraints.is_initiator
+
+ def is_active(self):
+ return self.get_state() == ChannelState.OPEN and self.peer_state == PeerState.GOOD
+
+ def funding_txn_minimum_depth(self):
+ return self.constraints.funding_txn_minimum_depth
+
+ def diagnostic_name(self):
+ if self.name:
+ return str(self.name)
+ return super().diagnostic_name()
+
+ def set_onion_key(self, key: int, value: bytes):
+ self.onion_keys[key] = value
+
+ def pop_onion_key(self, key: int) -> bytes:
+ return self.onion_keys.pop(key)
+
+ def set_data_loss_protect_remote_pcp(self, key, value):
+ self.data_loss_protect_remote_pcp[key] = value
+
+ def get_data_loss_protect_remote_pcp(self, key):
+ return self.data_loss_protect_remote_pcp.get(key)
+
+ def get_local_pubkey(self) -> bytes:
+ return self.lnworker.node_keypair.pubkey
+
+ def set_remote_update(self, payload: dict) -> None:
+ """Save the ChannelUpdate message for the incoming direction of this channel.
+ This message contains info we need to populate private route hints when
+ creating invoices.
+ """
+ assert payload['short_channel_id'] in [self.short_channel_id, self.get_local_scid_alias()]
+ from .channel_db import ChannelDB
+ ChannelDB.verify_channel_update(payload, start_node=self.node_id)
+ raw = payload['raw']
+ self.storage['remote_update'] = raw.hex()
+
+ def get_remote_update(self) -> Optional[bytes]:
+ return bfh(self.storage.get('remote_update')) if self.storage.get('remote_update') else None
+
+ def add_or_update_peer_addr(self, peer: LNPeerAddr) -> None:
+ if 'peer_network_addresses' not in self.storage:
+ self.storage['peer_network_addresses'] = {}
+ self.storage['peer_network_addresses'][peer.net_addr_str()] = now()
+
+ def get_peer_addresses(self) -> Iterator[LNPeerAddr]:
+ # sort by timestamp: most recent first
+ addrs = sorted(self.storage.get('peer_network_addresses', {}).items(),
+ key=lambda x: x[1], reverse=True)
+ for net_addr_str, ts in addrs:
+ net_addr = NetAddress.from_string(net_addr_str)
+ yield LNPeerAddr(host=str(net_addr.host), port=net_addr.port, pubkey=self.node_id)
+
+ def save_remote_peer_sent_error(self, original_error: bytes):
+ # We save the original arbitrary text(/bytes) error, as received.
+ # The length is only implicitly limited by the BOLT-08 max msg size.
+ # Receiving an error usually results in the channel getting closed, so
+ # there is likely no need to store multiple errors. We only store one, and overwrite.
+ self.storage['remote_peer_sent_error'] = original_error.hex()
+
+ def get_remote_peer_sent_error(self) -> Optional[str]:
+ original_error = self.storage.get('remote_peer_sent_error')
+ if not original_error:
+ return None
+ err_bytes = bytes.fromhex(original_error)
+ safe_str = error_text_bytes_to_safe_str(err_bytes) # note: truncates
+ return safe_str
+
+ def get_outgoing_gossip_channel_update(self, *, scid: ShortChannelID = None) -> bytes:
+ """
+ scid: to be put into the channel_update message instead of the real scid, as this might be an scid alias
+ """
+ if self._outgoing_channel_update is not None and scid is None:
+ return self._outgoing_channel_update
+ if scid is None:
+ scid = self.short_channel_id
+ sorted_node_ids = list(sorted([self.node_id, self.get_local_pubkey()]))
+ channel_flags = b'\x00' if sorted_node_ids[0] == self.get_local_pubkey() else b'\x01'
+ htlc_maximum_msat = min(self.config[REMOTE].max_htlc_value_in_flight_msat, 1000 * self.constraints.capacity)
+
+ chan_upd = encode_msg(
+ "channel_update",
+ short_channel_id=scid,
+ channel_flags=channel_flags,
+ message_flags=b'\x01',
+ cltv_expiry_delta=self.forwarding_cltv_delta,
+ htlc_minimum_msat=self.config[REMOTE].htlc_minimum_msat,
+ htlc_maximum_msat=htlc_maximum_msat,
+ fee_base_msat=self.forwarding_fee_base_msat,
+ fee_proportional_millionths=self.forwarding_fee_proportional_millionths,
+ chain_hash=constants.net.rev_genesis_bytes(),
+ timestamp=now(),
+ )
+ sighash = sha256d(chan_upd[2 + 64:])
+ sig = ecc.ECPrivkey(self.lnworker.node_keypair.privkey).ecdsa_sign(sighash, sigencode=ecc.ecdsa_sig64_from_r_and_s)
+ message_type, payload = decode_msg(chan_upd)
+ payload['signature'] = sig
+ chan_upd = encode_msg(message_type, **payload)
+
+ self._outgoing_channel_update = chan_upd
+ return chan_upd
+
+ def construct_channel_announcement_without_sigs(self) -> Tuple[bytes, bool]:
+ bitcoin_keys = [
+ self.config[REMOTE].multisig_key.pubkey,
+ self.config[LOCAL].multisig_key.pubkey]
+ node_ids = [self.node_id, self.get_local_pubkey()]
+ is_reverse = node_ids[0] > node_ids[1]
+ if is_reverse:
+ node_ids.reverse()
+ bitcoin_keys.reverse()
+ chan_ann = encode_msg(
+ "channel_announcement",
+ len=0,
+ features=b'',
+ chain_hash=constants.net.rev_genesis_bytes(),
+ short_channel_id=self.short_channel_id,
+ node_id_1=node_ids[0],
+ node_id_2=node_ids[1],
+ bitcoin_key_1=bitcoin_keys[0],
+ bitcoin_key_2=bitcoin_keys[1],
+ )
+ return chan_ann, is_reverse
+
+ def get_channel_announcement_hash(self):
+ chan_ann, _ = self.construct_channel_announcement_without_sigs()
+ return sha256d(chan_ann[256+2:])
+
+ def is_static_remotekey_enabled(self) -> bool:
+ channel_type = ChannelType(self.storage.get('channel_type'))
+ return bool(channel_type & ChannelType.OPTION_STATIC_REMOTEKEY)
+
+ def is_zeroconf(self) -> bool:
+ channel_type = ChannelType(self.storage.get('channel_type'))
+ return bool(channel_type & ChannelType.OPTION_ZEROCONF)
+
+ def remove_zeroconf_flag(self) -> None:
+ if not self.is_zeroconf():
+ return
+ channel_type = ChannelType(self.storage.get('channel_type'))
+ self.storage['channel_type'] = channel_type & ~ChannelType.OPTION_ZEROCONF
+
+ def get_sweep_address(self) -> str:
+ # TODO: in case of unilateral close with pending HTLCs, this address will be reused
+ if self.has_anchors():
+ addr = self.lnworker.wallet.get_new_sweep_address_for_channel()
+ elif self.is_static_remotekey_enabled():
+ our_payment_pubkey = self.config[LOCAL].payment_basepoint.pubkey
+ addr = make_commitment_output_to_remote_address(our_payment_pubkey, has_anchors=self.has_anchors())
+ assert self.lnworker.wallet.is_mine(addr)
+ return addr
+
+ def has_anchors(self) -> bool:
+ channel_type = ChannelType(self.storage.get('channel_type'))
+ return bool(channel_type & ChannelType.OPTION_ANCHORS)
+
+ def get_wallet_addresses_channel_might_want_reserved(self) -> Sequence[str]:
+ assert self.is_static_remotekey_enabled()
+ our_payment_pubkey = self.config[LOCAL].payment_basepoint.pubkey
+ to_remote_address = make_commitment_output_to_remote_address(our_payment_pubkey, has_anchors=self.has_anchors())
+ return [to_remote_address]
+
+ def get_feerate(self, subject: HTLCOwner, *, ctn: int) -> int:
+ # returns feerate in sat/kw
+ return self.hm.get_feerate(subject, ctn)
+
+ def get_oldest_unrevoked_feerate(self, subject: HTLCOwner) -> int:
+ return self.hm.get_feerate_in_oldest_unrevoked_ctx(subject)
+
+ def get_latest_feerate(self, subject: HTLCOwner) -> int:
+ return self.hm.get_feerate_in_latest_ctx(subject)
+
+ def get_next_feerate(self, subject: HTLCOwner) -> int:
+ return self.hm.get_feerate_in_next_ctx(subject)
+
+ def get_payments(self, status=None) -> Mapping[bytes, List[HTLCWithStatus]]:
+ out = defaultdict(list)
+ for direction, htlc in self.hm.all_htlcs_ever():
+ htlc_proposer = LOCAL if direction is SENT else REMOTE
+ if self.hm.was_htlc_failed(htlc_id=htlc.htlc_id, htlc_proposer=htlc_proposer):
+ _status = 'failed'
+ elif self.hm.was_htlc_preimage_released(htlc_id=htlc.htlc_id, htlc_proposer=htlc_proposer):
+ _status = 'settled'
+ else:
+ _status = 'inflight'
+ if status and status != _status:
+ continue
+ htlc_with_status = HTLCWithStatus(
+ channel_id=self.channel_id, htlc=htlc, direction=direction, status=_status)
+ out[htlc.payment_hash].append(htlc_with_status)
+ return out
+
+ def open_with_first_pcp(self, remote_pcp: bytes, remote_sig: bytes) -> None:
+ with self.db_lock:
+ self.config[REMOTE].current_per_commitment_point = remote_pcp
+ self.config[REMOTE].next_per_commitment_point = None
+ self.config[LOCAL].current_commitment_signature = remote_sig
+ self.hm.channel_open_finished()
+ self.peer_state = PeerState.GOOD
+
+ def get_state_for_GUI(self):
+ cs_name = super().get_state_for_GUI()
+ if self.is_closed() or self.unconfirmed_closing_txid:
+ return cs_name
+ ps = self.peer_state
+ if ps != PeerState.GOOD:
+ return ps.name
+ return cs_name
+
+ def set_can_send_ctx_updates(self, b: bool) -> None:
+ self._can_send_ctx_updates = b
+
+ def can_update_ctx(self, *, proposer: HTLCOwner) -> bool:
+ """Whether proposer is allowed to send commitment_signed, revoke_and_ack,
+ and update_* messages.
+ """
+ if self.get_state() not in (ChannelState.OPEN, ChannelState.SHUTDOWN):
+ return False
+ if self.peer_state != PeerState.GOOD:
+ return False
+ if proposer == LOCAL:
+ if not self._can_send_ctx_updates:
+ return False
+ return True
+
+ def can_send_update_add_htlc(self) -> bool:
+ return self.can_update_ctx(proposer=LOCAL) and self.is_open()
+
+ def is_frozen_for_sending(self) -> bool:
+ if self.lnworker.uses_trampoline() and not self.lnworker.is_trampoline_peer(self.node_id):
+ return True
+ return self.storage.get('frozen_for_sending', False)
+
+ def set_frozen_for_sending(self, b: bool) -> None:
+ self.storage['frozen_for_sending'] = bool(b)
+ util.trigger_callback('channel', self.lnworker.wallet, self)
+
+ def is_frozen_for_receiving(self) -> bool:
+ return self.storage.get('frozen_for_receiving', False)
+
+ def set_frozen_for_receiving(self, b: bool) -> None:
+ self.storage['frozen_for_receiving'] = bool(b)
+ util.trigger_callback('channel', self.lnworker.wallet, self)
+
+ def _assert_can_add_htlc(self, *, htlc_proposer: HTLCOwner, amount_msat: int,
+ ignore_min_htlc_value: bool = False) -> None:
+ """Raises PaymentFailure if the htlc_proposer cannot add this new HTLC.
+ (this is relevant both for forwarding and endpoint)
+ """
+ htlc_receiver = htlc_proposer.inverted()
+ # note: all these tests are about the *receiver's* *next* commitment transaction,
+ # and the constraints are the ones imposed by their config
+ ctn = self.get_next_ctn(htlc_receiver)
+ chan_config = self.config[htlc_receiver]
+ if self.get_state() != ChannelState.OPEN:
+ raise PaymentFailure(f"Channel not open. {self.get_state()!r}")
+ if not self.can_update_ctx(proposer=htlc_proposer):
+ raise PaymentFailure(f"cannot update channel. {self.get_state()!r} {self.peer_state!r}")
+ if htlc_proposer == LOCAL:
+ if not self.can_send_update_add_htlc():
+ raise PaymentFailure('Channel cannot add htlc')
+
+ # check htlc raw value
+ if not ignore_min_htlc_value:
+ if amount_msat <= 0:
+ raise PaymentFailure("HTLC value must be positive")
+ if amount_msat < chan_config.htlc_minimum_msat:
+ # todo: for incoming htlcs this could be handled more gracefully with `amount_below_minimum`
+ raise PaymentFailure(f'HTLC value too small: {amount_msat} msat')
+
+ if self.htlc_slots_left(htlc_proposer) == 0:
+ raise PaymentFailure('Too many HTLCs already in channel')
+
+ if amount_msat > self.remaining_max_inflight(htlc_receiver, strict=False):
+ raise PaymentFailure(
+ f'HTLC value sum (sum of pending htlcs plus new htlc) '
+ f'would exceed max allowed: {chan_config.max_htlc_value_in_flight_msat/1000} sat')
+
+ # check proposer can afford htlc
+ max_can_send_msat = self.available_to_spend(htlc_proposer)
+ if max_can_send_msat < amount_msat:
+ raise PaymentFailure(f'Not enough balance. can send: {max_can_send_msat}, tried: {amount_msat}')
+
+ def htlc_slots_left(self, htlc_proposer: HTLCOwner) -> int:
+ # check "max_accepted_htlcs"
+ htlc_receiver = htlc_proposer.inverted()
+ ctn = self.get_next_ctn(htlc_receiver)
+ chan_config = self.config[htlc_receiver]
+ # If proposer is LOCAL we apply stricter checks as that is behaviour we can control.
+ # This should lead to fewer disagreements (i.e. channels failing).
+ strict = (htlc_proposer == LOCAL)
+ if not strict:
+ # this is the loose check BOLT-02 specifies:
+ return chan_config.max_accepted_htlcs - len(self.hm.htlcs_by_direction(htlc_receiver, direction=RECEIVED, ctn=ctn))
+ else:
+ # however, c-lightning is a lot stricter, so extra checks:
+ # https://github.com/ElementsProject/lightning/blob/4dcd4ca1556b13b6964a10040ba1d5ef82de4788/channeld/full_channel.c#L581
+ max_concurrent_htlcs = min(
+ self.config[htlc_proposer].max_accepted_htlcs,
+ self.config[htlc_receiver].max_accepted_htlcs)
+ return max_concurrent_htlcs - len(self.hm.htlcs(htlc_receiver, ctn=ctn))
+
+ def remaining_max_inflight(self, htlc_receiver: HTLCOwner, *, strict: bool) -> int:
+ """
+ Checks max_htlc_value_in_flight_msat
+ strict = False -> how much we can accept according to BOLT2
+ strict = True -> how much the remote will accept to send to us (Eclair has stricter rules)
+ """
+ ctn = self.get_next_ctn(htlc_receiver)
+ current_htlc_sum = htlcsum(self.hm.htlcs_by_direction(htlc_receiver, direction=RECEIVED, ctn=ctn).values())
+ max_inflight = self.config[htlc_receiver].max_htlc_value_in_flight_msat
+ if strict and htlc_receiver == LOCAL:
+ # in order to send, eclair applies both local and remote max values
+ # https://github.com/ACINQ/eclair/blob/9b0c00a2a28d3ba6c7f3d01fbd2d8704ebbdc75d/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala#L503
+ max_inflight = min(
+ self.config[LOCAL].max_htlc_value_in_flight_msat,
+ self.config[REMOTE].max_htlc_value_in_flight_msat
+ )
+ return max_inflight - current_htlc_sum
+
+ def can_pay(self, amount_msat: int, *, check_frozen=False) -> bool:
+ """Returns whether we can add an HTLC of given value."""
+ if check_frozen and self.is_frozen_for_sending():
+ return False
+ try:
+ self._assert_can_add_htlc(htlc_proposer=LOCAL, amount_msat=amount_msat)
+ except PaymentFailure:
+ return False
+ return True
+
+ def can_receive(self, amount_msat: int, *, check_frozen=False,
+ ignore_min_htlc_value: bool = False) -> bool:
+ """Returns whether the remote can add an HTLC of given value."""
+ if check_frozen and self.is_frozen_for_receiving():
+ return False
+ try:
+ self._assert_can_add_htlc(
+ htlc_proposer=REMOTE,
+ amount_msat=amount_msat,
+ ignore_min_htlc_value=ignore_min_htlc_value)
+ except PaymentFailure:
+ return False
+ return True
+
+ def should_try_to_reestablish_peer(self) -> bool:
+ if self.peer_state != PeerState.DISCONNECTED:
+ return False
+ if self.should_request_force_close:
+ return True
+ return ChannelState.PREOPENING < self._state < ChannelState.CLOSING
+
+ def get_funding_address(self):
+ script = funding_output_script(self.config[LOCAL], self.config[REMOTE])
+ return redeem_script_to_address('p2wsh', script)
+
+ def add_htlc(self, htlc: UpdateAddHtlc) -> UpdateAddHtlc:
+ """Adds a new LOCAL HTLC to the channel.
+ Action must be initiated by LOCAL.
+ """
+ assert isinstance(htlc, UpdateAddHtlc)
+ self._assert_can_add_htlc(htlc_proposer=LOCAL, amount_msat=htlc.amount_msat)
+ if htlc.htlc_id is None:
+ htlc = dataclasses.replace(htlc, htlc_id=self.hm.get_next_htlc_id(LOCAL))
+ with self.db_lock:
+ self.hm.send_htlc(htlc)
+ self.logger.info("add_htlc")
+ return htlc
+
+ def receive_htlc(self, htlc: UpdateAddHtlc, onion_packet:bytes = None) -> UpdateAddHtlc:
+ """Adds a new REMOTE HTLC to the channel.
+ Action must be initiated by REMOTE.
+ """
+ assert isinstance(htlc, UpdateAddHtlc)
+ try:
+ self._assert_can_add_htlc(htlc_proposer=REMOTE, amount_msat=htlc.amount_msat)
+ except PaymentFailure as e:
+ raise RemoteMisbehaving(e) from e
+ if htlc.htlc_id is None: # used in unit tests
+ htlc = dataclasses.replace(htlc, htlc_id=self.hm.get_next_htlc_id(REMOTE))
+ with self.db_lock:
+ self.hm.recv_htlc(htlc)
+ if onion_packet:
+ self.unfulfilled_htlcs[htlc.htlc_id] = onion_packet.hex()
+
+ self.logger.info("receive_htlc")
+ return htlc
+
+ def sign_next_commitment(self) -> Tuple[bytes, Sequence[bytes]]:
+ """Returns signatures for our next remote commitment tx.
+ Action must be initiated by LOCAL.
+ Finally, the next remote ctx becomes the latest remote ctx.
+ """
+ # TODO: when more channel types are supported, this method should depend on channel type
+ next_remote_ctn = self.get_next_ctn(REMOTE)
+ self.logger.info(f"sign_next_commitment. ctn={next_remote_ctn}")
+ assert not self.is_closed(), self.get_state()
+
+ pending_remote_commitment = self.get_next_commitment(REMOTE)
+ sig_64 = sign_and_get_sig_string(pending_remote_commitment, self.config[LOCAL], self.config[REMOTE])
+ self.logger.debug(f"sign_next_commitment. {pending_remote_commitment.serialize()=}. {sig_64.hex()=}")
+
+ their_remote_htlc_privkey_number = derive_privkey(
+ int.from_bytes(self.config[LOCAL].htlc_basepoint.privkey, 'big'),
+ self.config[REMOTE].next_per_commitment_point)
+ their_remote_htlc_privkey = their_remote_htlc_privkey_number.to_bytes(32, 'big')
+
+ htlcsigs = []
+ htlc_to_ctx_output_idx_map = map_htlcs_to_ctx_output_idxs(chan=self,
+ ctx=pending_remote_commitment,
+ pcp=self.config[REMOTE].next_per_commitment_point,
+ subject=REMOTE,
+ ctn=next_remote_ctn)
+ for (direction, htlc), (ctx_output_idx, htlc_relative_idx) in htlc_to_ctx_output_idx_map.items():
+ _script, htlc_tx = make_htlc_tx_with_open_channel(chan=self,
+ pcp=self.config[REMOTE].next_per_commitment_point,
+ subject=REMOTE,
+ ctn=next_remote_ctn,
+ htlc_direction=direction,
+ commit=pending_remote_commitment,
+ ctx_output_idx=ctx_output_idx,
+ htlc=htlc)
+ if self.has_anchors():
+ # we send a signature with the following sighash flags
+ # for the peer to be able to replace inputs and outputs
+ htlc_tx.inputs()[0].sighash = Sighash.ANYONECANPAY | Sighash.SINGLE
+ sig = htlc_tx.sign_txin(0, their_remote_htlc_privkey)
+ htlc_sig = ecc.ecdsa_sig64_from_der_sig(sig[:-1])
+ htlcsigs.append((ctx_output_idx, htlc_sig))
+ htlcsigs.sort()
+ htlcsigs = [x[1] for x in htlcsigs]
+ with self.db_lock:
+ self.hm.send_ctx()
+ return sig_64, htlcsigs
+
+ def receive_new_commitment(self, sig: bytes, htlc_sigs: Sequence[bytes]) -> None:
+ """Processes signatures for our next local commitment tx, sent by the REMOTE.
+ Action must be initiated by REMOTE.
+ If all checks pass, the next local ctx becomes the latest local ctx.
+ """
+ # TODO in many failure cases below, we should "fail" the channel (force-close)
+ # TODO: when more channel types are supported, this method should depend on channel type
+ next_local_ctn = self.get_next_ctn(LOCAL)
+ self.logger.info(f"receive_new_commitment. ctn={next_local_ctn}, len(htlc_sigs)={len(htlc_sigs)}")
+ assert not self.is_closed(), self.get_state()
+
+ assert len(htlc_sigs) == 0 or type(htlc_sigs[0]) is bytes
+
+ pending_local_commitment = self.get_next_commitment(LOCAL)
+ pre_hash = pending_local_commitment.serialize_preimage(0)
+ msg_hash = sha256d(pre_hash)
+ if not ECPubkey(self.config[REMOTE].multisig_key.pubkey).ecdsa_verify(sig, msg_hash):
+ raise LNProtocolWarning(
+ f'failed verifying signature for our updated commitment transaction. '
+ f'sig={sig.hex()}. '
+ f'msg_hash={msg_hash.hex()}. '
+ f'pubkey={self.config[REMOTE].multisig_key.pubkey}. '
+ f'ctx={pending_local_commitment.serialize()} '
+ )
+
+ htlc_sigs_string = b''.join(htlc_sigs)
+
+ _secret, pcp = self.get_secret_and_point(subject=LOCAL, ctn=next_local_ctn)
+
+ htlc_to_ctx_output_idx_map = map_htlcs_to_ctx_output_idxs(chan=self,
+ ctx=pending_local_commitment,
+ pcp=pcp,
+ subject=LOCAL,
+ ctn=next_local_ctn)
+ if len(htlc_to_ctx_output_idx_map) != len(htlc_sigs):
+ raise LNProtocolWarning(f'htlc sigs failure. recv {len(htlc_sigs)} sigs, expected {len(htlc_to_ctx_output_idx_map)}')
+ for (direction, htlc), (ctx_output_idx, htlc_relative_idx) in htlc_to_ctx_output_idx_map.items():
+ htlc_sig = htlc_sigs[htlc_relative_idx]
+ self._verify_htlc_sig(htlc=htlc,
+ htlc_sig=htlc_sig,
+ htlc_direction=direction,
+ pcp=pcp,
+ ctx=pending_local_commitment,
+ ctx_output_idx=ctx_output_idx,
+ ctn=next_local_ctn)
+ with self.db_lock:
+ self.hm.recv_ctx()
+ self.config[LOCAL].current_commitment_signature=sig
+ self.config[LOCAL].current_htlc_signatures=htlc_sigs_string
+
+ def _verify_htlc_sig(self, *, htlc: UpdateAddHtlc, htlc_sig: bytes, htlc_direction: Direction,
+ pcp: bytes, ctx: Transaction, ctx_output_idx: int, ctn: int) -> None:
+ _script, htlc_tx = make_htlc_tx_with_open_channel(chan=self,
+ pcp=pcp,
+ subject=LOCAL,
+ ctn=ctn,
+ htlc_direction=htlc_direction,
+ commit=ctx,
+ ctx_output_idx=ctx_output_idx,
+ htlc=htlc)
+ if self.has_anchors():
+ # peer sent us a signature for our ctx using anchor sighash flags
+ htlc_tx.inputs()[0].sighash = Sighash.ANYONECANPAY | Sighash.SINGLE
+ pre_hash = htlc_tx.serialize_preimage(0)
+ msg_hash = sha256d(pre_hash)
+ remote_htlc_pubkey = derive_pubkey(self.config[REMOTE].htlc_basepoint.pubkey, pcp)
+ if not ECPubkey(remote_htlc_pubkey).ecdsa_verify(htlc_sig, msg_hash):
+ raise LNProtocolWarning(
+ f'failed verifying HTLC signatures: {htlc=}, {htlc_direction=}. '
+ f'htlc_tx={htlc_tx.serialize()}. '
+ f'htlc_sig={htlc_sig.hex()}. '
+ f'remote_htlc_pubkey={remote_htlc_pubkey.hex()}. '
+ f'msg_hash={msg_hash.hex()}. '
+ f'ctx={ctx.serialize()}. '
+ f'ctx_output_idx={ctx_output_idx}. '
+ f'ctn={ctn}. '
+ )
+
+ def get_remote_htlc_sig_for_htlc(self, *, htlc_relative_idx: int) -> bytes:
+ data = self.config[LOCAL].current_htlc_signatures
+ htlc_sigs = list(chunks(data, 64))
+ htlc_sig = htlc_sigs[htlc_relative_idx]
+ remote_sighash = Sighash.ALL if not self.has_anchors() else Sighash.ANYONECANPAY | Sighash.SINGLE
+ remote_htlc_sig = ecc.ecdsa_der_sig_from_ecdsa_sig64(htlc_sig) + Sighash.to_sigbytes(remote_sighash)
+ return remote_htlc_sig
+
+ def revoke_current_commitment(self):
+ self.logger.info("revoke_current_commitment")
+ assert not self.is_closed(), self.get_state()
+ new_ctn = self.get_latest_ctn(LOCAL)
+ new_ctx = self.get_latest_commitment(LOCAL)
+ if not self.signature_fits(new_ctx):
+ # this should never fail; as receive_new_commitment already did this test
+ raise Exception("refusing to revoke as remote sig does not fit")
+ with self.db_lock:
+ self.hm.send_rev()
+ last_secret, last_point = self.get_secret_and_point(LOCAL, new_ctn - 1)
+ next_secret, next_point = self.get_secret_and_point(LOCAL, new_ctn + 1)
+ return RevokeAndAck(last_secret, next_point)
+
+ def receive_revocation(self, revocation: RevokeAndAck):
+ self.logger.info("receive_revocation")
+ assert not self.is_closed(), self.get_state()
+ new_ctn = self.get_latest_ctn(REMOTE)
+ cur_point = self.config[REMOTE].current_per_commitment_point
+ derived_point = ecc.ECPrivkey(revocation.per_commitment_secret).get_public_key_bytes(compressed=True)
+ if cur_point != derived_point:
+ raise Exception('revoked secret not for current point')
+ with self.db_lock:
+ self.revocation_store.add_next_entry(revocation.per_commitment_secret)
+ ##### start applying fee/htlc changes
+ self.hm.recv_rev()
+ self.config[REMOTE].current_per_commitment_point=self.config[REMOTE].next_per_commitment_point
+ self.config[REMOTE].next_per_commitment_point=revocation.next_per_commitment_point
+ assert new_ctn == self.get_oldest_unrevoked_ctn(REMOTE)
+ # lnworker callbacks
+ sent = self.hm.sent_in_ctn(new_ctn)
+ for htlc in sent:
+ self.lnworker.htlc_fulfilled(self, htlc.payment_hash, htlc.htlc_id)
+ failed = self.hm.failed_in_ctn(new_ctn)
+ for htlc in failed:
+ try:
+ error_bytes, failure_message = self._receive_fail_reasons.pop(htlc.htlc_id)
+ except KeyError:
+ error_bytes, failure_message = None, None
+ self.lnworker.htlc_failed(self, htlc.payment_hash, htlc.htlc_id, error_bytes, failure_message)
+
+ def extract_preimage_from_htlc_txin(self, txin: TxInput, *, is_deeply_mined: bool) -> None:
+ from . import lnutil
+ from .crypto import ripemd
+ from .transaction import match_script_against_template, script_GetOp
+ from .lnonion import OnionRoutingFailure, OnionFailureCode
+ witness = txin.witness_elements()
+ witness_script = witness[-1]
+ script_ops = [x for x in script_GetOp(witness_script)]
+ if match_script_against_template(witness_script, lnutil.WITNESS_TEMPLATE_OFFERED_HTLC, debug=False) \
+ or match_script_against_template(witness_script, lnutil.WITNESS_TEMPLATE_OFFERED_HTLC_ANCHORS, debug=False):
+ ripemd_payment_hash = script_ops[21][1]
+ elif match_script_against_template(witness_script, lnutil.WITNESS_TEMPLATE_RECEIVED_HTLC, debug=False) \
+ or match_script_against_template(witness_script, lnutil.WITNESS_TEMPLATE_RECEIVED_HTLC_ANCHORS, debug=False):
+ ripemd_payment_hash = script_ops[14][1]
+ else:
+ return
+ found = {}
+ for direction, htlc in itertools.chain(
+ self.hm.get_htlcs_in_oldest_unrevoked_ctx(REMOTE),
+ self.hm.get_htlcs_in_latest_ctx(REMOTE)):
+ if ripemd(htlc.payment_hash) == ripemd_payment_hash:
+ is_sent = direction == RECEIVED
+ found[htlc.htlc_id] = (htlc, is_sent)
+ for direction, htlc in itertools.chain(
+ self.hm.get_htlcs_in_oldest_unrevoked_ctx(LOCAL),
+ self.hm.get_htlcs_in_latest_ctx(LOCAL)):
+ if ripemd(htlc.payment_hash) == ripemd_payment_hash:
+ is_sent = direction == SENT
+ found[htlc.htlc_id] = (htlc, is_sent)
+ if not found:
+ return
+ if len(witness) == 5: # HTLC success tx
+ preimage = witness[3]
+ elif len(witness) == 3: # spending offered HTLC directly from ctx
+ preimage = witness[1]
+ else:
+ preimage = None # HTLC timeout tx
+ if preimage:
+ assert ripemd(sha256(preimage)) == ripemd_payment_hash
+ payment_hash = sha256(preimage)
+ if self.lnworker.get_preimage(payment_hash) is not None:
+ return
+ # ^ note: log message text grepped for in regtests
+ self.logger.info(f"found preimage in witness of length {len(witness)}, for {payment_hash.hex()}")
+
+ # Mark the htlc as fulfilled or failed.
+ # If we forwarded this, this ensures that the success/failure is propagated back on the incoming channel.
+ # FIXME we only look at outgoing htlcs that have a corresponding output in the commitment tx,
+ # however we should also look at those that do not. E.g. a small value htlc might not create an output
+ # but we should still propagate back success or failure on the incoming link. And it is not just about
+ # small value htlcs: even a large htlc might not appear in the outgoing channel's ctx, e.g. maybe it was
+ # not committed yet - we should still make sure it gets removed on the incoming channel. (see #9631)
+ if preimage:
+ self.lnworker.save_preimage(payment_hash, preimage, mark_as_public=True)
+ for htlc, is_sent in found.values():
+ if is_sent:
+ self.lnworker.htlc_fulfilled(self, payment_hash, htlc.htlc_id)
+ else:
+ # htlc timeout tx
+ if not is_deeply_mined:
+ return
+ failure = OnionRoutingFailure(code=OnionFailureCode.PERMANENT_CHANNEL_FAILURE, data=b'')
+ for htlc, is_sent in found.values():
+ if is_sent:
+ self.logger.info(f'htlc timeout tx: failing htlc {is_sent}')
+ self.lnworker.htlc_failed(
+ self,
+ payment_hash=htlc.payment_hash,
+ htlc_id=htlc.htlc_id,
+ error_bytes=None,
+ failure_message=failure)
+
+ def balance(self, whose: HTLCOwner, *, ctx_owner=HTLCOwner.LOCAL, ctn: int = None) -> int:
+ assert type(whose) is HTLCOwner
+ initial = self.config[whose].initial_msat
+ return self.hm.get_balance_msat(whose=whose,
+ ctx_owner=ctx_owner,
+ ctn=ctn,
+ initial_balance_msat=initial)
+
+ def balance_minus_outgoing_htlcs(self, whose: HTLCOwner, *, ctx_owner: HTLCOwner = HTLCOwner.LOCAL,
+ ctn: int = None) -> int:
+ assert type(whose) is HTLCOwner
+ if ctn is None:
+ ctn = self.get_next_ctn(ctx_owner)
+ committed_balance = self.balance(whose, ctx_owner=ctx_owner, ctn=ctn)
+ direction = RECEIVED if whose != ctx_owner else SENT
+ balance_in_htlcs = self.balance_tied_up_in_htlcs_by_direction(ctx_owner, ctn=ctn, direction=direction)
+ return committed_balance - balance_in_htlcs
+
+ def balance_tied_up_in_htlcs_by_direction(self, ctx_owner: HTLCOwner = LOCAL, *, ctn: int = None,
+ direction: Direction):
+ # in msat
+ if ctn is None:
+ ctn = self.get_next_ctn(ctx_owner)
+ return htlcsum(self.hm.htlcs_by_direction(ctx_owner, direction, ctn).values())
+
+ def has_unsettled_htlcs(self) -> bool:
+ return len(self.hm.htlcs(LOCAL)) + len(self.hm.htlcs(REMOTE)) > 0
+
+ def available_to_spend(self, subject: HTLCOwner) -> int:
+ """The usable balance of 'subject' in msat, after taking reserve and fees (and anchors) into
+ consideration. Note that fees (and hence the result) fluctuate even without user interaction.
+ """
+ assert type(subject) is HTLCOwner
+ sender = subject
+ receiver = subject.inverted()
+ initiator = LOCAL if self.constraints.is_initiator else REMOTE # the initiator/funder pays on-chain fees
+
+ def consider_ctx(*, ctx_owner: HTLCOwner, is_htlc_dust: bool) -> int:
+ ctn = self.get_next_ctn(ctx_owner)
+ sender_balance_msat = self.balance_minus_outgoing_htlcs(whose=sender, ctx_owner=ctx_owner, ctn=ctn)
+ receiver_balance_msat = self.balance_minus_outgoing_htlcs(whose=receiver, ctx_owner=ctx_owner, ctn=ctn)
+ sender_reserve_msat = self.config[receiver].reserve_sat * 1000
+ receiver_reserve_msat = self.config[sender].reserve_sat * 1000
+ num_htlcs_in_ctx = len(self.included_htlcs(ctx_owner, SENT, ctn=ctn) + self.included_htlcs(ctx_owner, RECEIVED, ctn=ctn))
+ feerate = self.get_feerate(ctx_owner, ctn=ctn)
+ ctx_fees_msat = calc_fees_for_commitment_tx(
+ num_htlcs=num_htlcs_in_ctx,
+ feerate=feerate,
+ is_local_initiator=self.constraints.is_initiator,
+ round_to_sat=False,
+ has_anchors=self.has_anchors()
+ )
+ htlc_fee_msat = fee_for_htlc_output(feerate=feerate)
+ htlc_trim_func = received_htlc_trim_threshold_sat if ctx_owner == receiver else offered_htlc_trim_threshold_sat
+ htlc_trim_threshold_msat = htlc_trim_func(dust_limit_sat=self.config[ctx_owner].dust_limit_sat, feerate=feerate, has_anchors=self.has_anchors()) * 1000
+
+ # the sender cannot spend below its reserve
+ max_send_msat = sender_balance_msat - sender_reserve_msat
+
+ # reserve a fee spike buffer
+ # see https://github.com/lightningnetwork/lightning-rfc/pull/740
+ if sender == initiator == LOCAL:
+ fee_spike_buffer = calc_fees_for_commitment_tx(
+ num_htlcs=num_htlcs_in_ctx + int(not is_htlc_dust) + 1,
+ feerate=2 * feerate,
+ is_local_initiator=self.constraints.is_initiator,
+ round_to_sat=False,
+ has_anchors=self.has_anchors())[sender]
+ max_send_msat -= fee_spike_buffer
+ # we can't enforce the fee spike buffer on the remote party
+ elif sender == initiator == REMOTE:
+ max_send_msat -= ctx_fees_msat[sender]
+
+ # initiator pays for anchor outputs
+ if sender == initiator and self.has_anchors():
+ max_send_msat -= 2 * FIXED_ANCHOR_SAT * 1000
+
+ # handle the transaction fees for the HTLC transaction
+ if is_htlc_dust:
+ # nobody pays additional HTLC transaction fees
+ return min(max_send_msat, htlc_trim_threshold_msat - 1)
+ else:
+ # somebody has to pay for the additional HTLC transaction fees
+ if sender == initiator:
+ return max_send_msat - htlc_fee_msat
+ else:
+ # check if the receiver can afford to pay for the HTLC transaction fees
+ new_receiver_balance = receiver_balance_msat - receiver_reserve_msat - ctx_fees_msat[receiver] - htlc_fee_msat
+ if self.has_anchors():
+ new_receiver_balance -= 2 * FIXED_ANCHOR_SAT * 1000
+ if new_receiver_balance < 0:
+ return 0
+ return max_send_msat
+
+ max_send_msat = min(
+ max(
+ consider_ctx(ctx_owner=receiver, is_htlc_dust=True),
+ consider_ctx(ctx_owner=receiver, is_htlc_dust=False),
+ ),
+ max(
+ consider_ctx(ctx_owner=sender, is_htlc_dust=True),
+ consider_ctx(ctx_owner=sender, is_htlc_dust=False),
+ ),
+ )
+
+ max_send_msat = min(max_send_msat, self.remaining_max_inflight(receiver, strict=True))
+ if self.htlc_slots_left(sender) == 0:
+ max_send_msat = 0
+
+ max_send_msat = max(max_send_msat, 0)
+ return max_send_msat
+
+
+ def included_htlcs(self, subject: HTLCOwner, direction: Direction, ctn: int = None, *,
+ feerate: int = None) -> List[UpdateAddHtlc]:
+ """Returns list of non-dust HTLCs for subject's commitment tx at ctn,
+ filtered by direction (of HTLCs).
+ """
+ assert type(subject) is HTLCOwner
+ assert type(direction) is Direction
+ if ctn is None:
+ ctn = self.get_oldest_unrevoked_ctn(subject)
+ if feerate is None:
+ feerate = self.get_feerate(subject, ctn=ctn)
+ conf = self.config[subject]
+ if direction == RECEIVED:
+ threshold_sat = received_htlc_trim_threshold_sat(dust_limit_sat=conf.dust_limit_sat, feerate=feerate, has_anchors=self.has_anchors())
+ else:
+ threshold_sat = offered_htlc_trim_threshold_sat(dust_limit_sat=conf.dust_limit_sat, feerate=feerate, has_anchors=self.has_anchors())
+ htlcs = self.hm.htlcs_by_direction(subject, direction, ctn=ctn).values()
+ return list(filter(lambda htlc: htlc.amount_msat // 1000 >= threshold_sat, htlcs))
+
+ def get_secret_and_point(self, subject: HTLCOwner, ctn: int) -> Tuple[Optional[bytes], bytes]:
+ assert type(subject) is HTLCOwner
+ assert ctn >= 0, ctn
+ offset = ctn - self.get_oldest_unrevoked_ctn(subject)
+ if subject == REMOTE:
+ if offset > 1:
+ raise RemoteCtnTooFarInFuture(f"offset: {offset}")
+ conf = self.config[REMOTE]
+ if offset == 1:
+ secret = None
+ point = conf.next_per_commitment_point
+ elif offset == 0:
+ secret = None
+ point = conf.current_per_commitment_point
+ else:
+ secret = self.revocation_store.retrieve_secret(RevocationStore.START_INDEX - ctn)
+ point = secret_to_pubkey(int.from_bytes(secret, 'big'))
+ else:
+ secret = get_per_commitment_secret_from_seed(self.config[LOCAL].per_commitment_secret_seed, RevocationStore.START_INDEX - ctn)
+ point = secret_to_pubkey(int.from_bytes(secret, 'big'))
+ return secret, point
+
+ def get_secret_and_commitment(self, subject: HTLCOwner, *, ctn: int) -> Tuple[Optional[bytes], PartialTransaction]:
+ secret, point = self.get_secret_and_point(subject, ctn)
+ ctx = self.make_commitment(subject, point, ctn)
+ return secret, ctx
+
+ def get_commitment(self, subject: HTLCOwner, *, ctn: int) -> PartialTransaction:
+ secret, ctx = self.get_secret_and_commitment(subject, ctn=ctn)
+ return ctx
+
+ def get_next_commitment(self, subject: HTLCOwner) -> PartialTransaction:
+ ctn = self.get_next_ctn(subject)
+ return self.get_commitment(subject, ctn=ctn)
+
+ def get_latest_commitment(self, subject: HTLCOwner) -> PartialTransaction:
+ ctn = self.get_latest_ctn(subject)
+ return self.get_commitment(subject, ctn=ctn)
+
+ def get_oldest_unrevoked_commitment(self, subject: HTLCOwner) -> PartialTransaction:
+ ctn = self.get_oldest_unrevoked_ctn(subject)
+ return self.get_commitment(subject, ctn=ctn)
+
+ def create_sweeptxs_for_watchtower(self, ctn: int) -> List[Transaction]:
+ from .lnsweep import sweep_their_ctx_watchtower
+ from .fee_policy import FeePolicy
+ from .transaction import PartialTxOutput, PartialTransaction
+ secret, ctx = self.get_secret_and_commitment(REMOTE, ctn=ctn)
+ txs = []
+ txins = sweep_their_ctx_watchtower(self, ctx, secret)
+ fee_policy = FeePolicy('eta:2')
+ for txin in txins:
+ output_idx = txin.prevout.out_idx
+ value = ctx.outputs()[output_idx].value
+ tx_size_bytes = 121
+ fee = fee_policy.estimate_fee(tx_size_bytes, network=self.lnworker.network, allow_fallback_to_static_rates=True)
+ outvalue = value - fee
+ sweep_outputs = [PartialTxOutput.from_address_and_value(self.get_sweep_address(), outvalue)]
+ sweep_tx = PartialTransaction.from_io([txin], sweep_outputs, version=2)
+ sig = sweep_tx.sign_txin(0, txin.privkey)
+ txin.witness = txin.make_witness(sig)
+ txs.append(sweep_tx)
+ return txs
+
+ def get_oldest_unrevoked_ctn(self, subject: HTLCOwner) -> int:
+ return self.hm.ctn_oldest_unrevoked(subject)
+
+ def get_latest_ctn(self, subject: HTLCOwner) -> int:
+ return self.hm.ctn_latest(subject)
+
+ def get_next_ctn(self, subject: HTLCOwner) -> int:
+ return self.hm.ctn_latest(subject) + 1
+
+ def total_msat(self, direction: Direction) -> int:
+ """Return the cumulative total msat amount received/sent so far."""
+ assert type(direction) is Direction
+ return htlcsum(self.hm.all_settled_htlcs_ever_by_direction(LOCAL, direction))
+
+ def settle_htlc(self, preimage: bytes, htlc_id: int) -> None:
+ """Settle/fulfill a pending received HTLC.
+ Action must be initiated by LOCAL.
+ """
+ self.logger.info("settle_htlc")
+ assert self.can_update_ctx(proposer=LOCAL), f"cannot update channel. {self.get_state()!r} {self.peer_state!r}"
+ htlc = self.hm.get_htlc_by_id(REMOTE, htlc_id)
+ if htlc.payment_hash != sha256(preimage):
+ raise Exception("incorrect preimage for HTLC")
+ assert htlc_id not in self.hm.log[REMOTE]['settles']
+ self.hm.send_settle(htlc_id)
+ self.htlc_settle_time[htlc_id] = now()
+ self.lnworker.save_preimage(htlc.payment_hash, preimage, mark_as_public=True)
+
+ def get_payment_hash(self, htlc_id: int) -> bytes:
+ htlc = self.hm.get_htlc_by_id(LOCAL, htlc_id)
+ return htlc.payment_hash
+
+ def receive_htlc_settle(self, preimage: bytes, htlc_id: int) -> None:
+ """Settle/fulfill a pending offered HTLC.
+ Action must be initiated by REMOTE.
+ """
+ self.logger.info("receive_htlc_settle")
+ assert self.can_update_ctx(proposer=REMOTE), f"cannot update channel. {self.get_state()!r} {self.peer_state!r}"
+ htlc = self.hm.get_htlc_by_id(LOCAL, htlc_id)
+ if htlc.payment_hash != sha256(preimage):
+ raise RemoteMisbehaving("received incorrect preimage for HTLC")
+ assert htlc_id not in self.hm.log[LOCAL]['settles']
+ with self.db_lock:
+ self.hm.recv_settle(htlc_id)
+ self.lnworker.save_preimage(htlc.payment_hash, preimage, mark_as_public=True)
+
+ def fail_htlc(self, htlc_id: int) -> None:
+ """Fail a pending received HTLC.
+ Action must be initiated by LOCAL.
+ """
+ self.logger.info("fail_htlc")
+ assert self.can_update_ctx(proposer=LOCAL), f"cannot update channel. {self.get_state()!r} {self.peer_state!r}"
+ with self.db_lock:
+ self.hm.send_fail(htlc_id)
+
+ def receive_fail_htlc(self, htlc_id: int, *,
+ error_bytes: Optional[bytes],
+ reason: Optional[OnionRoutingFailure] = None) -> None:
+ """Fail a pending offered HTLC.
+ Action must be initiated by REMOTE.
+ """
+ self.logger.info("receive_fail_htlc")
+ assert self.can_update_ctx(proposer=REMOTE), f"cannot update channel. {self.get_state()!r} {self.peer_state!r}"
+ with self.db_lock:
+ self.hm.recv_fail(htlc_id)
+ self._receive_fail_reasons[htlc_id] = (error_bytes, reason)
+
+ def get_next_fee(self, subject: HTLCOwner) -> int:
+ return self.constraints.capacity - sum(x.value for x in self.get_next_commitment(subject).outputs())
+
+ def get_latest_fee(self, subject: HTLCOwner) -> int:
+ return self.constraints.capacity - sum(x.value for x in self.get_latest_commitment(subject).outputs())
+
+ def update_fee(self, feerate: int, from_us: bool) -> None:
+ # feerate uses sat/kw
+ if self.constraints.is_initiator != from_us:
+ raise Exception(f"Cannot update_fee: wrong initiator. us: {from_us}")
+ if feerate < FEERATE_PER_KW_MIN_RELAY_LIGHTNING:
+ raise Exception(f"Cannot update_fee: feerate lower than min relay fee. {feerate} sat/kw. us: {from_us}")
+ sender = LOCAL if from_us else REMOTE
+ ctx_owner = -sender
+ ctn = self.get_next_ctn(ctx_owner)
+ sender_balance_msat = self.balance_minus_outgoing_htlcs(whose=sender, ctx_owner=ctx_owner, ctn=ctn)
+ sender_reserve_msat = self.config[-sender].reserve_sat * 1000
+ num_htlcs_in_ctx = len(self.included_htlcs(ctx_owner, SENT, ctn=ctn, feerate=feerate) +
+ self.included_htlcs(ctx_owner, RECEIVED, ctn=ctn, feerate=feerate))
+ ctx_fees_msat = calc_fees_for_commitment_tx(
+ num_htlcs=num_htlcs_in_ctx,
+ feerate=feerate,
+ is_local_initiator=self.constraints.is_initiator,
+ has_anchors=self.has_anchors()
+ )
+ remainder = sender_balance_msat - sender_reserve_msat - ctx_fees_msat[sender]
+ if remainder < 0:
+ raise Exception(f"Cannot update_fee. {sender} tried to update fee but they cannot afford it. "
+ f"Their balance would go below reserve: {remainder} msat missing.")
+ assert self.can_update_ctx(proposer=LOCAL if from_us else REMOTE), f"cannot update channel. {self.get_state()!r} {self.peer_state!r}. {from_us=}"
+ with self.db_lock:
+ if from_us:
+ self.hm.send_update_fee(feerate)
+ else:
+ self.hm.recv_update_fee(feerate)
+
+ def make_commitment(self, subject: HTLCOwner, this_point: bytes, ctn: int) -> PartialTransaction:
+ assert type(subject) is HTLCOwner
+ feerate = self.get_feerate(subject, ctn=ctn)
+ other = subject.inverted()
+ local_msat = self.balance(subject, ctx_owner=subject, ctn=ctn)
+ remote_msat = self.balance(other, ctx_owner=subject, ctn=ctn)
+ received_htlcs = self.hm.htlcs_by_direction(subject, RECEIVED, ctn).values()
+ sent_htlcs = self.hm.htlcs_by_direction(subject, SENT, ctn).values()
+ remote_msat -= htlcsum(received_htlcs)
+ local_msat -= htlcsum(sent_htlcs)
+ assert remote_msat >= 0
+ assert local_msat >= 0
+ # same htlcs as before, but now without dust.
+ received_htlcs = self.included_htlcs(subject, RECEIVED, ctn)
+ sent_htlcs = self.included_htlcs(subject, SENT, ctn)
+
+ this_config = self.config[subject]
+ other_config = self.config[-subject]
+ other_htlc_pubkey = derive_pubkey(other_config.htlc_basepoint.pubkey, this_point)
+ this_htlc_pubkey = derive_pubkey(this_config.htlc_basepoint.pubkey, this_point)
+ other_revocation_pubkey = derive_blinded_pubkey(other_config.revocation_basepoint.pubkey, this_point)
+ htlcs = [] # type: List[ScriptHtlc]
+ for is_received_htlc, htlc_list in zip((True, False), (received_htlcs, sent_htlcs)):
+ for htlc in htlc_list:
+ htlcs.append(ScriptHtlc(make_htlc_output_witness_script(
+ is_received_htlc=is_received_htlc,
+ remote_revocation_pubkey=other_revocation_pubkey,
+ remote_htlc_pubkey=other_htlc_pubkey,
+ local_htlc_pubkey=this_htlc_pubkey,
+ payment_hash=htlc.payment_hash,
+ cltv_abs=htlc.cltv_abs,
+ has_anchors=self.has_anchors()), htlc))
+ # note: maybe flip initiator here for fee purposes, we want LOCAL and REMOTE
+ # in the resulting dict to correspond to the to_local and to_remote *outputs* of the ctx
+ onchain_fees = calc_fees_for_commitment_tx(
+ num_htlcs=len(htlcs),
+ feerate=feerate,
+ is_local_initiator=self.constraints.is_initiator == (subject == LOCAL),
+ has_anchors=self.has_anchors(),
+ )
+ assert self.is_static_remotekey_enabled()
+ payment_pubkey = other_config.payment_basepoint.pubkey
+ return make_commitment(
+ ctn=ctn,
+ local_funding_pubkey=this_config.multisig_key.pubkey,
+ remote_funding_pubkey=other_config.multisig_key.pubkey,
+ remote_payment_pubkey=payment_pubkey,
+ funder_payment_basepoint=self.config[LOCAL if self.constraints.is_initiator else REMOTE].payment_basepoint.pubkey,
+ fundee_payment_basepoint=self.config[LOCAL if not self.constraints.is_initiator else REMOTE].payment_basepoint.pubkey,
+ revocation_pubkey=other_revocation_pubkey,
+ delayed_pubkey=derive_pubkey(this_config.delayed_basepoint.pubkey, this_point),
+ to_self_delay=other_config.to_self_delay,
+ funding_txid=self.funding_outpoint.txid,
+ funding_pos=self.funding_outpoint.output_index,
+ funding_sat=self.constraints.capacity,
+ local_amount=local_msat,
+ remote_amount=remote_msat,
+ dust_limit_sat=this_config.dust_limit_sat,
+ fees_per_participant=onchain_fees,
+ htlcs=htlcs,
+ has_anchors=self.has_anchors()
+ )
+
+ def make_closing_tx(self, local_script: bytes, remote_script: bytes,
+ fee_sat: int, *, drop_remote = False) -> Tuple[bytes, PartialTransaction]:
+ """ cooperative close """
+ _, outputs = make_commitment_outputs(
+ fees_per_participant={
+ LOCAL: fee_sat * 1000 if self.constraints.is_initiator else 0,
+ REMOTE: fee_sat * 1000 if not self.constraints.is_initiator else 0,
+ },
+ local_amount_msat=self.balance(LOCAL),
+ remote_amount_msat=self.balance(REMOTE) if not drop_remote else 0,
+ local_script=local_script,
+ remote_script=remote_script,
+ htlcs=[],
+ dust_limit_sat=self.config[LOCAL].dust_limit_sat,
+ has_anchors=self.has_anchors(),
+ local_anchor_script=None,
+ remote_anchor_script=None,
+ )
+
+ closing_tx = make_closing_tx(self.config[LOCAL].multisig_key.pubkey,
+ self.config[REMOTE].multisig_key.pubkey,
+ funding_txid=self.funding_outpoint.txid,
+ funding_pos=self.funding_outpoint.output_index,
+ funding_sat=self.constraints.capacity,
+ outputs=outputs)
+
+ der_sig = closing_tx.sign_txin(0, self.config[LOCAL].multisig_key.privkey)
+ sig = ecc.ecdsa_sig64_from_der_sig(der_sig[:-1])
+ return sig, closing_tx
+
+ def signature_fits(self, tx: PartialTransaction) -> bool:
+ remote_sig = self.config[LOCAL].current_commitment_signature
+ pre_hash = tx.serialize_preimage(0)
+ msg_hash = sha256d(pre_hash)
+ assert remote_sig
+ res = ECPubkey(self.config[REMOTE].multisig_key.pubkey).ecdsa_verify(remote_sig, msg_hash)
+ return res
+
+ def force_close_tx(self) -> PartialTransaction:
+ tx = self.get_latest_commitment(LOCAL)
+ assert self.signature_fits(tx)
+ tx.sign({self.config[LOCAL].multisig_key.pubkey: self.config[LOCAL].multisig_key.privkey})
+ remote_sig = self.config[LOCAL].current_commitment_signature
+ remote_sig = ecc.ecdsa_der_sig_from_ecdsa_sig64(remote_sig) + Sighash.to_sigbytes(Sighash.ALL)
+ tx.add_signature_to_txin(txin_idx=0,
+ signing_pubkey=self.config[REMOTE].multisig_key.pubkey,
+ sig=remote_sig)
+ assert tx.is_complete()
+ return tx
+
+ def get_close_options(self) -> Sequence[ChanCloseOption]:
+ # This method is used both in the GUI, and in lnpeer.schedule_force_closing
+ # in the latter case, the result does not depend on peer_state
+ ret = []
+ if not self.is_closed() and self.peer_state == PeerState.GOOD:
+ # If there are unsettled HTLCs, although is possible to cooperatively close,
+ # we choose not to expose that option in the GUI, because it is very likely
+ # that HTLCs will take a long time to settle (submarine swap, or stuck payment),
+ # and the close dialog would be taking a very long time to finish
+ if not self.has_unsettled_htlcs():
+ ret.append(ChanCloseOption.COOP_CLOSE)
+ ret.append(ChanCloseOption.REQUEST_REMOTE_FCLOSE)
+ if self.get_state() == ChannelState.WE_ARE_TOXIC:
+ ret.append(ChanCloseOption.REQUEST_REMOTE_FCLOSE)
+ if not self.is_closed() or self.get_state() == ChannelState.REQUESTED_FCLOSE:
+ ret.append(ChanCloseOption.LOCAL_FCLOSE)
+ assert not (self.get_state() == ChannelState.WE_ARE_TOXIC and ChanCloseOption.LOCAL_FCLOSE in ret), "local force-close unsafe if we are toxic"
+ return ret
+
+ def maybe_sweep_htlcs(self, ctx: Transaction, htlc_tx: Transaction) -> Dict[str, MaybeSweepInfo]:
+ # look at the output address, check if it matches
+ d = sweep_their_htlctx_justice(self, ctx, htlc_tx)
+ d2 = sweep_our_htlctx(self, ctx, htlc_tx)
+ d.update(d2)
+ return d
+
+ def has_pending_changes(self, subject: HTLCOwner) -> bool:
+ next_htlcs = self.hm.get_htlcs_in_next_ctx(subject)
+ latest_htlcs = self.hm.get_htlcs_in_latest_ctx(subject)
+ return not (next_htlcs == latest_htlcs and self.get_next_feerate(subject) == self.get_latest_feerate(subject))
+
+ def should_be_closed_due_to_expiring_htlcs(self, local_height: int) -> bool:
+ htlcs_we_could_reclaim = {} # type: Dict[Tuple[Direction, int], UpdateAddHtlc]
+ # If there is a received HTLC for which we already released the preimage
+ # but the remote did not revoke yet, and the CLTV of this HTLC is dangerously close
+ # to the present, then unilaterally close channel
+ recv_htlc_deadline_delta = lnutil.NBLOCK_DEADLINE_DELTA_BEFORE_EXPIRY_FOR_RECEIVED_HTLCS
+ for sub, dir, ctn in ((LOCAL, RECEIVED, self.get_latest_ctn(LOCAL)),
+ (REMOTE, SENT, self.get_oldest_unrevoked_ctn(REMOTE)),
+ (REMOTE, SENT, self.get_latest_ctn(REMOTE)),):
+ for htlc_id, htlc in self.hm.htlcs_by_direction(subject=sub, direction=dir, ctn=ctn).items():
+ if not self.hm.was_htlc_preimage_released(htlc_id=htlc_id, htlc_proposer=REMOTE):
+ continue
+ if htlc.cltv_abs - recv_htlc_deadline_delta > local_height:
+ continue
+ # Do not force-close if we just sent fulfill_htlc and have not received revack yet
+ if htlc_id in self.htlc_settle_time and now() - self.htlc_settle_time[htlc_id] < 30:
+ continue
+ htlcs_we_could_reclaim[(RECEIVED, htlc_id)] = htlc
+ # If there is an offered HTLC which has already expired (+ some grace period after), we
+ # will unilaterally close the channel and time out the HTLC
+ offered_htlc_deadline_delta = lnutil.NBLOCK_DEADLINE_DELTA_AFTER_EXPIRY_FOR_OFFERED_HTLCS
+ time_since_startup = now() - self.lnworker.instantiation_timestamp
+ for sub, dir, ctn in ((LOCAL, SENT, self.get_latest_ctn(LOCAL)),
+ (REMOTE, RECEIVED, self.get_oldest_unrevoked_ctn(REMOTE)),
+ (REMOTE, RECEIVED, self.get_latest_ctn(REMOTE)),):
+ for htlc_id, htlc in self.hm.htlcs_by_direction(subject=sub, direction=dir, ctn=ctn).items():
+ if htlc.cltv_abs + offered_htlc_deadline_delta > local_height:
+ continue
+ if time_since_startup < lnutil.TIME_FOR_OFFERED_HTLCS_TO_GET_FAILED_OFFCHAIN_ON_RESTART:
+ continue # give the peer some time to fail the htlc offchain
+ htlcs_we_could_reclaim[(SENT, htlc_id)] = htlc
+ # Note: previously we used a threshold concept, "min_value_worth_closing_channel_over_sat", and
+ # only force-closed the channel if the total value of these expiring htlcs was large enough.
+ # However, if we are forwarding, and an outgoing htlc expires, we should always close
+ # the outgoing channel (regardless of htlc value), so that we can propagate back the
+ # removal of the htlc in the incoming channel.
+ return len(htlcs_we_could_reclaim) > 0
+
+ def is_funding_tx_mined(self, funding_height):
+ funding_txid = self.funding_outpoint.txid
+ funding_idx = self.funding_outpoint.output_index
+ conf = funding_height.conf
+ if conf < self.funding_txn_minimum_depth():
+ #self.logger.info(f"funding tx is still not at sufficient depth. actual depth: {conf}")
+ return False
+ assert conf > 0 or self.is_zeroconf()
+ # check funding_tx amount and script
+ funding_tx = self.lnworker.lnwatcher.adb.get_transaction(funding_txid)
+ if not funding_tx:
+ self.logger.info(f"no funding_tx {funding_txid}")
+ return False
+ if funding_tx.is_coinbase_tx():
+ if conf < COINBASE_MATURITY:
+ # FIXME what about zeroconf? In the zeroconf case, is_funding_tx_mined is used as a late-check
+ # after the funding tx is already mined, to validate the funding output addr and value.
+ # If we return False here, we will force-close the channel.
+ # (though it's unlikely an LSP would open a zero-conf channel in a coinbase tx!)
+ # The proper way to fix this would be to have already validated the funding tx in the zeroconf case
+ # *before* we progress it to the OPEN chan state (just like we do it for non-zeroconf chans).
+ return False
+ outp = funding_tx.outputs()[funding_idx]
+ redeem_script = funding_output_script(self.config[REMOTE], self.config[LOCAL])
+ funding_address = redeem_script_to_address('p2wsh', redeem_script)
+ funding_sat = self.constraints.capacity
+ if not (outp.address == funding_address and outp.value == funding_sat):
+ self.logger.info('funding outpoint mismatch')
+ return False
+ return True
diff --git a/electrum/lnhtlc.py b/electrum/lnhtlc.py
new file mode 100644
index 000000000000..9901efe4db20
--- /dev/null
+++ b/electrum/lnhtlc.py
@@ -0,0 +1,627 @@
+from copy import deepcopy
+from typing import Sequence, Tuple, Dict, TYPE_CHECKING, Set
+
+from .lnutil import SENT, RECEIVED, LOCAL, REMOTE, HTLCOwner, UpdateAddHtlc, Direction, FeeUpdate
+from .util import bfh, with_lock
+
+if TYPE_CHECKING:
+ from .json_db import StoredDict
+
+LOG_TEMPLATE = {
+ 'adds': {}, # "side who offered htlc" -> htlc_id -> htlc
+ 'locked_in': {}, # "side who offered htlc" -> action -> htlc_id -> whose ctx -> ctn
+ 'settles': {}, # "side who offered htlc" -> action -> htlc_id -> whose ctx -> ctn
+ 'fails': {}, # "side who offered htlc" -> action -> htlc_id -> whose ctx -> ctn
+ 'fee_updates': {}, # "side who initiated fee update" -> index -> list of FeeUpdates
+ 'revack_pending': False,
+ 'next_htlc_id': 0,
+ 'ctn': -1, # oldest unrevoked ctx of sub
+}
+
+
+class HTLCManager:
+
+ def __init__(self, log: 'StoredDict', *, initiator=None, initial_feerate=None):
+
+ if len(log) == 0:
+ # note: "htlc_id" keys in dict are str! but due to json_db magic they can *almost* be treated as int...
+ log[LOCAL] = deepcopy(LOG_TEMPLATE)
+ log[REMOTE] = deepcopy(LOG_TEMPLATE)
+ log[LOCAL]['unacked_updates'] = {}
+ log[LOCAL]['was_revoke_last'] = False
+
+ # maybe bootstrap fee_updates if initial_feerate was provided
+ if initial_feerate is not None:
+ assert type(initial_feerate) is int
+ assert initiator in [LOCAL, REMOTE]
+ log[initiator]['fee_updates'][0] = FeeUpdate(rate=initial_feerate, ctn_local=0, ctn_remote=0)
+ self.log = log
+
+ # We need a lock as many methods of HTLCManager are accessed by both the asyncio thread and the GUI.
+ # lnchannel sometimes calls us with Channel.db_lock (== log.lock) already taken,
+ # and we ourselves often take log.lock (via StoredDict.__getitem__).
+ # Hence, to avoid deadlocks, we reuse this same lock.
+ self.lock = log.lock
+
+ self._init_maybe_active_htlc_ids()
+
+ @with_lock
+ def ctn_latest(self, sub: HTLCOwner) -> int:
+ """Return the ctn for the latest (newest that has a valid sig) ctx of sub"""
+ return self.ctn_oldest_unrevoked(sub) + int(self.is_revack_pending(sub))
+
+ def ctn_oldest_unrevoked(self, sub: HTLCOwner) -> int:
+ """Return the ctn for the oldest unrevoked ctx of sub"""
+ return self.log[sub]['ctn']
+
+ def is_revack_pending(self, sub: HTLCOwner) -> bool:
+ """Returns True iff sub was sent commitment_signed but they did not
+ send revoke_and_ack yet (sub has multiple unrevoked ctxs)
+ """
+ return self.log[sub]['revack_pending']
+
+ def _set_revack_pending(self, sub: HTLCOwner, pending: bool) -> None:
+ self.log[sub]['revack_pending'] = pending
+
+ def get_next_htlc_id(self, sub: HTLCOwner) -> int:
+ return self.log[sub]['next_htlc_id']
+
+ ##### Actions on channel:
+
+ @with_lock
+ def channel_open_finished(self):
+ self.log[LOCAL]['ctn'] = 0
+ self.log[REMOTE]['ctn'] = 0
+ self._set_revack_pending(LOCAL, False)
+ self._set_revack_pending(REMOTE, False)
+
+ @with_lock
+ def send_htlc(self, htlc: UpdateAddHtlc) -> UpdateAddHtlc:
+ htlc_id = htlc.htlc_id
+ if htlc_id != self.get_next_htlc_id(LOCAL):
+ raise Exception(f"unexpected local htlc_id. next should be "
+ f"{self.get_next_htlc_id(LOCAL)} but got {htlc_id}")
+ self.log[LOCAL]['adds'][htlc_id] = htlc
+ self.log[LOCAL]['locked_in'][htlc_id] = {LOCAL: None, REMOTE: self.ctn_latest(REMOTE)+1}
+ self.log[LOCAL]['next_htlc_id'] += 1
+ self._maybe_active_htlc_ids[LOCAL].add(htlc_id)
+ return htlc
+
+ @with_lock
+ def recv_htlc(self, htlc: UpdateAddHtlc) -> None:
+ htlc_id = htlc.htlc_id
+ if htlc_id != self.get_next_htlc_id(REMOTE):
+ raise Exception(f"unexpected remote htlc_id. next should be "
+ f"{self.get_next_htlc_id(REMOTE)} but got {htlc_id}")
+ self.log[REMOTE]['adds'][htlc_id] = htlc
+ self.log[REMOTE]['locked_in'][htlc_id] = {LOCAL: self.ctn_latest(LOCAL)+1, REMOTE: None}
+ self.log[REMOTE]['next_htlc_id'] += 1
+ self._maybe_active_htlc_ids[REMOTE].add(htlc_id)
+
+ @with_lock
+ def send_settle(self, htlc_id: int) -> None:
+ next_ctn = self.ctn_latest(REMOTE) + 1
+ if not self.is_htlc_active_at_ctn(ctx_owner=REMOTE, ctn=next_ctn, htlc_proposer=REMOTE, htlc_id=htlc_id):
+ raise Exception(f"(local) cannot remove htlc that is not there...")
+ self.log[REMOTE]['settles'][htlc_id] = {LOCAL: None, REMOTE: next_ctn}
+
+ @with_lock
+ def recv_settle(self, htlc_id: int) -> None:
+ next_ctn = self.ctn_latest(LOCAL) + 1
+ if not self.is_htlc_active_at_ctn(ctx_owner=LOCAL, ctn=next_ctn, htlc_proposer=LOCAL, htlc_id=htlc_id):
+ raise Exception(f"(remote) cannot remove htlc that is not there...")
+ self.log[LOCAL]['settles'][htlc_id] = {LOCAL: next_ctn, REMOTE: None}
+
+ @with_lock
+ def send_fail(self, htlc_id: int) -> None:
+ next_ctn = self.ctn_latest(REMOTE) + 1
+ if not self.is_htlc_active_at_ctn(ctx_owner=REMOTE, ctn=next_ctn, htlc_proposer=REMOTE, htlc_id=htlc_id):
+ raise Exception(f"(local) cannot remove htlc that is not there...")
+ self.log[REMOTE]['fails'][htlc_id] = {LOCAL: None, REMOTE: next_ctn}
+
+ @with_lock
+ def recv_fail(self, htlc_id: int) -> None:
+ next_ctn = self.ctn_latest(LOCAL) + 1
+ if not self.is_htlc_active_at_ctn(ctx_owner=LOCAL, ctn=next_ctn, htlc_proposer=LOCAL, htlc_id=htlc_id):
+ raise Exception(f"(remote) cannot remove htlc that is not there...")
+ self.log[LOCAL]['fails'][htlc_id] = {LOCAL: next_ctn, REMOTE: None}
+
+ @with_lock
+ def send_update_fee(self, feerate: int) -> None:
+ fee_update = FeeUpdate(rate=feerate,
+ ctn_local=None, ctn_remote=self.ctn_latest(REMOTE) + 1)
+ self._new_feeupdate(fee_update, subject=LOCAL)
+
+ @with_lock
+ def recv_update_fee(self, feerate: int) -> None:
+ fee_update = FeeUpdate(rate=feerate,
+ ctn_local=self.ctn_latest(LOCAL) + 1, ctn_remote=None)
+ self._new_feeupdate(fee_update, subject=REMOTE)
+
+ @with_lock
+ def _new_feeupdate(self, fee_update: FeeUpdate, subject: HTLCOwner) -> None:
+ # overwrite last fee update if not yet committed to by anyone; otherwise append
+ d = self.log[subject]['fee_updates']
+ #assert type(d) is StoredDict
+ n = len(d)
+ last_fee_update = d[n-1]
+ if (last_fee_update.ctn_local is None or last_fee_update.ctn_local > self.ctn_latest(LOCAL)) \
+ and (last_fee_update.ctn_remote is None or last_fee_update.ctn_remote > self.ctn_latest(REMOTE)):
+ d[n-1] = fee_update
+ else:
+ d[n] = fee_update
+
+ @with_lock
+ def send_ctx(self) -> None:
+ assert self.ctn_latest(REMOTE) == self.ctn_oldest_unrevoked(REMOTE), (self.ctn_latest(REMOTE), self.ctn_oldest_unrevoked(REMOTE))
+ self._set_revack_pending(REMOTE, True)
+ self.log[LOCAL]['was_revoke_last'] = False
+
+ @with_lock
+ def recv_ctx(self) -> None:
+ assert self.ctn_latest(LOCAL) == self.ctn_oldest_unrevoked(LOCAL), (self.ctn_latest(LOCAL), self.ctn_oldest_unrevoked(LOCAL))
+ self._set_revack_pending(LOCAL, True)
+
+ @with_lock
+ def send_rev(self) -> None:
+ self.log[LOCAL]['ctn'] += 1
+ self._set_revack_pending(LOCAL, False)
+ self.log[LOCAL]['was_revoke_last'] = True
+ # htlcs
+ for htlc_id in self._maybe_active_htlc_ids[REMOTE]:
+ ctns = self.log[REMOTE]['locked_in'][htlc_id]
+ if ctns[REMOTE] is None and ctns[LOCAL] <= self.ctn_latest(LOCAL):
+ ctns[REMOTE] = self.ctn_latest(REMOTE) + 1
+ for log_action in ('settles', 'fails'):
+ for htlc_id in self._maybe_active_htlc_ids[LOCAL]:
+ ctns = self.log[LOCAL][log_action].get(htlc_id, None)
+ if ctns is None: continue
+ if ctns[REMOTE] is None and ctns[LOCAL] <= self.ctn_latest(LOCAL):
+ ctns[REMOTE] = self.ctn_latest(REMOTE) + 1
+ self._update_maybe_active_htlc_ids()
+ # fee updates
+ for k, fee_update in list(self.log[REMOTE]['fee_updates'].items()):
+ if fee_update.ctn_remote is None and fee_update.ctn_local <= self.ctn_latest(LOCAL):
+ fee_update.ctn_remote = self.ctn_latest(REMOTE) + 1
+
+ @with_lock
+ def recv_rev(self) -> None:
+ self.log[REMOTE]['ctn'] += 1
+ self._set_revack_pending(REMOTE, False)
+ # htlcs
+ for htlc_id in self._maybe_active_htlc_ids[LOCAL]:
+ ctns = self.log[LOCAL]['locked_in'][htlc_id]
+ if ctns[LOCAL] is None and ctns[REMOTE] <= self.ctn_latest(REMOTE):
+ ctns[LOCAL] = self.ctn_latest(LOCAL) + 1
+ for log_action in ('settles', 'fails'):
+ for htlc_id in self._maybe_active_htlc_ids[REMOTE]:
+ ctns = self.log[REMOTE][log_action].get(htlc_id, None)
+ if ctns is None: continue
+ if ctns[LOCAL] is None and ctns[REMOTE] <= self.ctn_latest(REMOTE):
+ ctns[LOCAL] = self.ctn_latest(LOCAL) + 1
+ self._update_maybe_active_htlc_ids()
+ # fee updates
+ for k, fee_update in list(self.log[LOCAL]['fee_updates'].items()):
+ if fee_update.ctn_local is None and fee_update.ctn_remote <= self.ctn_latest(REMOTE):
+ fee_update.ctn_local = self.ctn_latest(LOCAL) + 1
+
+ # no need to keep local update raw msgs anymore, they have just been ACKed.
+ self.log[LOCAL]['unacked_updates'].pop(self.log[REMOTE]['ctn'], None)
+
+ @with_lock
+ def _update_maybe_active_htlc_ids(self) -> None:
+ # - Loosely, we want a set that contains the htlcs that are
+ # not "removed and revoked from all ctxs of both parties". (self._maybe_active_htlc_ids)
+ # It is guaranteed that those htlcs are in the set, but older htlcs might be there too:
+ # there is a sanity margin of 1 ctn -- this relaxes the care needed re order of method calls.
+ # - balance_delta is in sync with maybe_active_htlc_ids. When htlcs are removed from the latter,
+ # balance_delta is updated to reflect that htlc.
+ sanity_margin = 1
+ for htlc_proposer in (LOCAL, REMOTE):
+ for log_action in ('settles', 'fails'):
+ for htlc_id in list(self._maybe_active_htlc_ids[htlc_proposer]):
+ ctns = self.log[htlc_proposer][log_action].get(htlc_id, None)
+ if ctns is None: continue
+ if (ctns[LOCAL] is not None
+ and ctns[LOCAL] <= self.ctn_oldest_unrevoked(LOCAL) - sanity_margin
+ and ctns[REMOTE] is not None
+ and ctns[REMOTE] <= self.ctn_oldest_unrevoked(REMOTE) - sanity_margin):
+ self._maybe_active_htlc_ids[htlc_proposer].remove(htlc_id)
+ if log_action == 'settles':
+ htlc = self.log[htlc_proposer]['adds'][htlc_id] # type: UpdateAddHtlc
+ self._balance_delta -= htlc.amount_msat * htlc_proposer
+
+ @with_lock
+ def _init_maybe_active_htlc_ids(self):
+ # first idx is "side who offered htlc":
+ self._maybe_active_htlc_ids = {LOCAL: set(), REMOTE: set()} # type: Dict[HTLCOwner, Set[int]]
+ # add all htlcs
+ self._balance_delta = 0 # the balance delta of LOCAL since channel open
+ for htlc_proposer in (LOCAL, REMOTE):
+ for htlc_id in self.log[htlc_proposer]['adds']:
+ self._maybe_active_htlc_ids[htlc_proposer].add(htlc_id)
+ # remove old htlcs
+ self._update_maybe_active_htlc_ids()
+
+ @with_lock
+ def discard_unsigned_remote_updates(self):
+ """Discard updates sent by the remote, that the remote itself
+ did not yet sign (i.e. there was no corresponding commitment_signed msg)
+ """
+ # htlcs added
+ for htlc_id, ctns in list(self.log[REMOTE]['locked_in'].items()):
+ if ctns[LOCAL] > self.ctn_latest(LOCAL):
+ del self.log[REMOTE]['locked_in'][htlc_id]
+ del self.log[REMOTE]['adds'][htlc_id]
+ self._maybe_active_htlc_ids[REMOTE].discard(htlc_id)
+ if self.log[REMOTE]['locked_in']:
+ self.log[REMOTE]['next_htlc_id'] = max([int(x) for x in self.log[REMOTE]['locked_in'].keys()]) + 1
+ else:
+ self.log[REMOTE]['next_htlc_id'] = 0
+ # htlcs removed
+ for log_action in ('settles', 'fails'):
+ for htlc_id, ctns in list(self.log[LOCAL][log_action].items()):
+ if ctns[LOCAL] > self.ctn_latest(LOCAL):
+ del self.log[LOCAL][log_action][htlc_id]
+ # fee updates
+ for k, fee_update in list(self.log[REMOTE]['fee_updates'].items()):
+ if fee_update.ctn_local > self.ctn_latest(LOCAL):
+ self.log[REMOTE]['fee_updates'].pop(k)
+
+ @with_lock
+ def store_local_update_raw_msg(self, raw_update_msg: bytes, *, is_commitment_signed: bool) -> None:
+ """We need to be able to replay unacknowledged updates we sent to the remote
+ in case of disconnections. Hence, raw update and commitment_signed messages
+ are stored temporarily (until they are acked)."""
+ # self.log[LOCAL]['unacked_updates'][ctn_idx] is a list of raw messages
+ # containing some number of updates and then a single commitment_signed
+ if is_commitment_signed:
+ ctn_idx = self.ctn_latest(REMOTE)
+ else:
+ ctn_idx = self.ctn_latest(REMOTE) + 1
+ l = self.log[LOCAL]['unacked_updates'].get(ctn_idx, [])
+ l.append(raw_update_msg.hex())
+ self.log[LOCAL]['unacked_updates'][ctn_idx] = l
+
+ @with_lock
+ def get_unacked_local_updates(self) -> Dict[int, Sequence[bytes]]:
+ #return self.log[LOCAL]['unacked_updates']
+ return {ctn: [bfh(msg) for msg in messages]
+ for ctn, messages in self.log[LOCAL]['unacked_updates'].items()}
+
+ @with_lock
+ def was_revoke_last(self) -> bool:
+ """Whether we sent a revoke_and_ack after the last commitment_signed we sent."""
+ return self.log[LOCAL].get('was_revoke_last') or False
+
+ ##### Queries re HTLCs:
+
+ def get_htlc_by_id(self, htlc_proposer: HTLCOwner, htlc_id: int) -> UpdateAddHtlc:
+ return self.log[htlc_proposer]['adds'][htlc_id]
+
+ @with_lock
+ def is_htlc_active_at_ctn(self, *, ctx_owner: HTLCOwner, ctn: int,
+ htlc_proposer: HTLCOwner, htlc_id: int) -> bool:
+ htlc_id = int(htlc_id)
+ if htlc_id >= self.get_next_htlc_id(htlc_proposer):
+ return False
+ settles = self.log[htlc_proposer]['settles']
+ fails = self.log[htlc_proposer]['fails']
+ ctns = self.log[htlc_proposer]['locked_in'][htlc_id]
+ if ctns[ctx_owner] is not None and ctns[ctx_owner] <= ctn:
+ not_settled = htlc_id not in settles or settles[htlc_id][ctx_owner] is None or settles[htlc_id][ctx_owner] > ctn
+ not_failed = htlc_id not in fails or fails[htlc_id][ctx_owner] is None or fails[htlc_id][ctx_owner] > ctn
+ if not_settled and not_failed:
+ return True
+ return False
+
+ @with_lock
+ def is_htlc_irrevocably_added_yet(
+ self,
+ *,
+ ctx_owner: HTLCOwner = None,
+ htlc_proposer: HTLCOwner,
+ htlc_id: int,
+ ) -> bool:
+ """Returns whether `add_htlc` was irrevocably committed to `ctx_owner's` ctx.
+ If `ctx_owner` is None, both parties' ctxs are checked.
+ """
+ in_local = self._is_htlc_irrevocably_added_yet(
+ ctx_owner=LOCAL, htlc_proposer=htlc_proposer, htlc_id=htlc_id)
+ in_remote = self._is_htlc_irrevocably_added_yet(
+ ctx_owner=REMOTE, htlc_proposer=htlc_proposer, htlc_id=htlc_id)
+ if ctx_owner is None:
+ return in_local and in_remote
+ elif ctx_owner == LOCAL:
+ return in_local
+ elif ctx_owner == REMOTE:
+ return in_remote
+ else:
+ raise Exception(f"unexpected ctx_owner: {ctx_owner!r}")
+
+ @with_lock
+ def _is_htlc_irrevocably_added_yet(
+ self,
+ *,
+ ctx_owner: HTLCOwner,
+ htlc_proposer: HTLCOwner,
+ htlc_id: int,
+ ) -> bool:
+ if htlc_id >= self.get_next_htlc_id(htlc_proposer):
+ return False
+ ctns = self.log[htlc_proposer]['locked_in'][htlc_id]
+ if ctns[ctx_owner] is None:
+ return False
+ return ctns[ctx_owner] <= self.ctn_oldest_unrevoked(ctx_owner)
+
+ @with_lock
+ def is_htlc_irrevocably_removed_yet(
+ self,
+ *,
+ ctx_owner: HTLCOwner = None,
+ htlc_proposer: HTLCOwner,
+ htlc_id: int,
+ ) -> bool:
+ """Returns whether the removal of an htlc was irrevocably committed to `ctx_owner's` ctx.
+ The removal can either be a fulfill/settle or a fail; they are not distinguished.
+ If `ctx_owner` is None, both parties' ctxs are checked.
+ """
+ in_local = self._is_htlc_irrevocably_removed_yet(
+ ctx_owner=LOCAL, htlc_proposer=htlc_proposer, htlc_id=htlc_id)
+ in_remote = self._is_htlc_irrevocably_removed_yet(
+ ctx_owner=REMOTE, htlc_proposer=htlc_proposer, htlc_id=htlc_id)
+ if ctx_owner is None:
+ return in_local and in_remote
+ elif ctx_owner == LOCAL:
+ return in_local
+ elif ctx_owner == REMOTE:
+ return in_remote
+ else:
+ raise Exception(f"unexpected ctx_owner: {ctx_owner!r}")
+
+ @with_lock
+ def _is_htlc_irrevocably_removed_yet(
+ self,
+ *,
+ ctx_owner: HTLCOwner,
+ htlc_proposer: HTLCOwner,
+ htlc_id: int,
+ ) -> bool:
+ if htlc_id >= self.get_next_htlc_id(htlc_proposer):
+ return False
+ if htlc_id in self.log[htlc_proposer]['settles']:
+ ctn_of_settle = self.log[htlc_proposer]['settles'][htlc_id][ctx_owner]
+ else:
+ ctn_of_settle = None
+ if htlc_id in self.log[htlc_proposer]['fails']:
+ ctn_of_fail = self.log[htlc_proposer]['fails'][htlc_id][ctx_owner]
+ else:
+ ctn_of_fail = None
+ ctn_of_rm = ctn_of_settle or ctn_of_fail or None
+ if ctn_of_rm is None:
+ return False
+ return ctn_of_rm <= self.ctn_oldest_unrevoked(ctx_owner)
+
+ @with_lock
+ def htlcs_by_direction(self, subject: HTLCOwner, direction: Direction,
+ ctn: int = None) -> Dict[int, UpdateAddHtlc]:
+ """Return the dict of received or sent (depending on direction) HTLCs
+ in subject's ctx at ctn, keyed by htlc_id.
+
+ direction is relative to subject!
+ """
+ assert type(subject) is HTLCOwner
+ assert type(direction) is Direction
+ if ctn is None:
+ ctn = self.ctn_oldest_unrevoked(subject)
+ d = {}
+ # subject's ctx
+ # party is the proposer of the HTLCs
+ party = subject if direction == SENT else subject.inverted()
+ if ctn >= self.ctn_oldest_unrevoked(subject):
+ considered_htlc_ids = self._maybe_active_htlc_ids[party]
+ else: # ctn is too old; need to consider full log (slow...)
+ considered_htlc_ids = self.log[party]['locked_in']
+ for htlc_id in considered_htlc_ids:
+ htlc_id = int(htlc_id)
+ if self.is_htlc_active_at_ctn(ctx_owner=subject, ctn=ctn, htlc_proposer=party, htlc_id=htlc_id):
+ d[htlc_id] = self.log[party]['adds'][htlc_id]
+ return d
+
+ @with_lock
+ def htlcs(self, subject: HTLCOwner, ctn: int = None) -> Sequence[Tuple[Direction, UpdateAddHtlc]]:
+ """Return the list of HTLCs in subject's ctx at ctn."""
+ assert type(subject) is HTLCOwner
+ if ctn is None:
+ ctn = self.ctn_oldest_unrevoked(subject)
+ l = []
+ l += [(SENT, x) for x in self.htlcs_by_direction(subject, SENT, ctn).values()]
+ l += [(RECEIVED, x) for x in self.htlcs_by_direction(subject, RECEIVED, ctn).values()]
+ return l
+
+ @with_lock
+ def get_htlcs_in_oldest_unrevoked_ctx(self, subject: HTLCOwner) -> Sequence[Tuple[Direction, UpdateAddHtlc]]:
+ assert type(subject) is HTLCOwner
+ ctn = self.ctn_oldest_unrevoked(subject)
+ return self.htlcs(subject, ctn)
+
+ @with_lock
+ def get_htlcs_in_latest_ctx(self, subject: HTLCOwner) -> Sequence[Tuple[Direction, UpdateAddHtlc]]:
+ assert type(subject) is HTLCOwner
+ ctn = self.ctn_latest(subject)
+ return self.htlcs(subject, ctn)
+
+ @with_lock
+ def get_htlcs_in_next_ctx(self, subject: HTLCOwner) -> Sequence[Tuple[Direction, UpdateAddHtlc]]:
+ assert type(subject) is HTLCOwner
+ ctn = self.ctn_latest(subject) + 1
+ return self.htlcs(subject, ctn)
+
+ def was_htlc_preimage_released(self, *, htlc_id: int, htlc_proposer: HTLCOwner) -> bool:
+ settles = self.log[htlc_proposer]['settles']
+ if htlc_id not in settles:
+ return False
+ return settles[htlc_id][htlc_proposer] is not None
+
+ def was_htlc_failed(self, *, htlc_id: int, htlc_proposer: HTLCOwner) -> bool:
+ """Returns whether an HTLC has been (or will be if we already know) failed."""
+ fails = self.log[htlc_proposer]['fails']
+ if htlc_id not in fails:
+ return False
+ return fails[htlc_id][htlc_proposer] is not None
+
+ @with_lock
+ def all_settled_htlcs_ever_by_direction(self, subject: HTLCOwner, direction: Direction,
+ ctn: int = None) -> Sequence[UpdateAddHtlc]:
+ """Return the list of all HTLCs that have been ever settled in subject's
+ ctx up to ctn, filtered to only "direction".
+ """
+ assert type(subject) is HTLCOwner
+ if ctn is None:
+ ctn = self.ctn_oldest_unrevoked(subject)
+ # subject's ctx
+ # party is the proposer of the HTLCs
+ party = subject if direction == SENT else subject.inverted()
+ d = []
+ for htlc_id, ctns in self.log[party]['settles'].items():
+ if ctns[subject] is not None and ctns[subject] <= ctn:
+ d.append(self.log[party]['adds'][htlc_id])
+ return d
+
+ @with_lock
+ def all_settled_htlcs_ever(self, subject: HTLCOwner, ctn: int = None) -> Sequence[Tuple[Direction, UpdateAddHtlc]]:
+ """Return the list of all HTLCs that have been ever settled in subject's
+ ctx up to ctn.
+ """
+ assert type(subject) is HTLCOwner
+ if ctn is None:
+ ctn = self.ctn_oldest_unrevoked(subject)
+ sent = [(SENT, x) for x in self.all_settled_htlcs_ever_by_direction(subject, SENT, ctn)]
+ received = [(RECEIVED, x) for x in self.all_settled_htlcs_ever_by_direction(subject, RECEIVED, ctn)]
+ return sent + received
+
+ @with_lock
+ def all_htlcs_ever(self) -> Sequence[Tuple[Direction, UpdateAddHtlc]]:
+ sent = [(SENT, htlc) for htlc in self.log[LOCAL]['adds'].values()]
+ received = [(RECEIVED, htlc) for htlc in self.log[REMOTE]['adds'].values()]
+ return sent + received
+
+ @with_lock
+ def get_balance_msat(self, whose: HTLCOwner, *, ctx_owner=HTLCOwner.LOCAL, ctn: int = None,
+ initial_balance_msat: int) -> int:
+ """Returns the balance of 'whose' in 'ctx' at 'ctn'.
+ Only HTLCs that have been settled by that ctn are counted.
+ """
+ if ctn is None:
+ ctn = self.ctn_oldest_unrevoked(ctx_owner)
+ balance = initial_balance_msat
+ if ctn >= self.ctn_oldest_unrevoked(ctx_owner):
+ balance += self._balance_delta * whose
+ considered_sent_htlc_ids = self._maybe_active_htlc_ids[whose]
+ considered_recv_htlc_ids = self._maybe_active_htlc_ids[-whose]
+ else: # ctn is too old; need to consider full log (slow...)
+ considered_sent_htlc_ids = self.log[whose]['settles']
+ considered_recv_htlc_ids = self.log[-whose]['settles']
+ # sent htlcs
+ for htlc_id in considered_sent_htlc_ids:
+ ctns = self.log[whose]['settles'].get(htlc_id, None)
+ if ctns is None:
+ continue
+ if ctns[ctx_owner] is not None and ctns[ctx_owner] <= ctn:
+ htlc = self.log[whose]['adds'][htlc_id]
+ balance -= htlc.amount_msat
+ # recv htlcs
+ for htlc_id in considered_recv_htlc_ids:
+ ctns = self.log[-whose]['settles'].get(htlc_id, None)
+ if ctns is None:
+ continue
+ if ctns[ctx_owner] is not None and ctns[ctx_owner] <= ctn:
+ htlc = self.log[-whose]['adds'][htlc_id]
+ balance += htlc.amount_msat
+ return balance
+
+ @with_lock
+ def _get_htlcs_that_got_removed_exactly_at_ctn(
+ self, ctn: int, *, ctx_owner: HTLCOwner, htlc_proposer: HTLCOwner, log_action: str,
+ ) -> Sequence[UpdateAddHtlc]:
+ if ctn >= self.ctn_oldest_unrevoked(ctx_owner):
+ considered_htlc_ids = self._maybe_active_htlc_ids[htlc_proposer]
+ else: # ctn is too old; need to consider full log (slow...)
+ considered_htlc_ids = self.log[htlc_proposer][log_action]
+ htlcs = []
+ for htlc_id in considered_htlc_ids:
+ ctns = self.log[htlc_proposer][log_action].get(htlc_id, None)
+ if ctns is None:
+ continue
+ if ctns[ctx_owner] == ctn:
+ htlcs.append(self.log[htlc_proposer]['adds'][htlc_id])
+ return htlcs
+
+ def received_in_ctn(self, local_ctn: int) -> Sequence[UpdateAddHtlc]:
+ """
+ received htlcs that became fulfilled when we send a revocation.
+ we check only local, because they are committed in the remote ctx first.
+ """
+ return self._get_htlcs_that_got_removed_exactly_at_ctn(local_ctn,
+ ctx_owner=LOCAL,
+ htlc_proposer=REMOTE,
+ log_action='settles')
+
+ def sent_in_ctn(self, remote_ctn: int) -> Sequence[UpdateAddHtlc]:
+ """
+ sent htlcs that became fulfilled when we received a revocation
+ we check only remote, because they are committed in the local ctx first.
+ """
+ return self._get_htlcs_that_got_removed_exactly_at_ctn(remote_ctn,
+ ctx_owner=REMOTE,
+ htlc_proposer=LOCAL,
+ log_action='settles')
+
+ def failed_in_ctn(self, remote_ctn: int) -> Sequence[UpdateAddHtlc]:
+ """
+ sent htlcs that became failed when we received a revocation
+ we check only remote, because they are committed in the local ctx first.
+ """
+ return self._get_htlcs_that_got_removed_exactly_at_ctn(remote_ctn,
+ ctx_owner=REMOTE,
+ htlc_proposer=LOCAL,
+ log_action='fails')
+
+ ##### Queries re Fees:
+ # note: feerates are in sat/kw everywhere in this file
+
+ @with_lock
+ def get_feerate(self, subject: HTLCOwner, ctn: int) -> int:
+ """Return feerate (sat/kw) used in subject's commitment txn at ctn."""
+ ctn = max(0, ctn) # FIXME rm this
+ # only one party can update fees; use length of logs to figure out which:
+ assert not (len(self.log[LOCAL]['fee_updates']) > 0 and len(self.log[REMOTE]['fee_updates']) > 0)
+ fee_log = self.log[LOCAL]['fee_updates'] # type: Sequence[FeeUpdate]
+ if len(self.log[REMOTE]['fee_updates']) > 0:
+ fee_log = self.log[REMOTE]['fee_updates']
+ # binary search
+ left = 0
+ right = len(fee_log)
+ while True:
+ i = (left + right) // 2
+ ctn_at_i = fee_log[i].ctn_local if subject == LOCAL else fee_log[i].ctn_remote
+ if right - left <= 1:
+ break
+ if ctn_at_i is None: # Nones can only be on the right end
+ right = i
+ continue
+ if ctn_at_i <= ctn: # among equals, we want the rightmost
+ left = i
+ else:
+ right = i
+ assert ctn_at_i <= ctn
+ return fee_log[i].rate
+
+ def get_feerate_in_oldest_unrevoked_ctx(self, subject: HTLCOwner) -> int:
+ return self.get_feerate(subject=subject, ctn=self.ctn_oldest_unrevoked(subject))
+
+ def get_feerate_in_latest_ctx(self, subject: HTLCOwner) -> int:
+ return self.get_feerate(subject=subject, ctn=self.ctn_latest(subject))
+
+ def get_feerate_in_next_ctx(self, subject: HTLCOwner) -> int:
+ return self.get_feerate(subject=subject, ctn=self.ctn_latest(subject) + 1)
diff --git a/electrum/lnmsg.py b/electrum/lnmsg.py
new file mode 100644
index 000000000000..e8ad41d24fa9
--- /dev/null
+++ b/electrum/lnmsg.py
@@ -0,0 +1,668 @@
+import os
+import csv
+import io
+from typing import Callable, Tuple, Any, Dict, List, Sequence, Union, Optional, Mapping
+from types import MappingProxyType
+from collections import OrderedDict
+
+from .lnutil import OnionFailureCodeMetaFlag
+
+
+class FailedToParseMsg(Exception):
+ msg_type_int: Optional[int] = None
+ msg_type_name: Optional[str] = None
+
+class UnknownMsgType(FailedToParseMsg): pass
+class UnknownOptionalMsgType(UnknownMsgType): pass
+class UnknownMandatoryMsgType(UnknownMsgType): pass
+
+class MalformedMsg(FailedToParseMsg): pass
+class UnknownMsgFieldType(MalformedMsg): pass
+class UnexpectedEndOfStream(MalformedMsg): pass
+class FieldEncodingNotMinimal(MalformedMsg): pass
+class UnknownMandatoryTLVRecordType(MalformedMsg): pass
+class MsgTrailingGarbage(MalformedMsg): pass
+class MsgInvalidFieldOrder(MalformedMsg): pass
+class UnexpectedFieldSizeForEncoder(MalformedMsg): pass
+
+
+def _num_remaining_bytes_to_read(fd: io.BytesIO) -> int:
+ cur_pos = fd.tell()
+ end_pos = fd.seek(0, io.SEEK_END)
+ fd.seek(cur_pos)
+ return end_pos - cur_pos
+
+
+def _assert_can_read_at_least_n_bytes(fd: io.BytesIO, n: int) -> None:
+ # note: it's faster to read n bytes and then check if we read n, than
+ # to assert we can read at least n and then read n bytes.
+ nremaining = _num_remaining_bytes_to_read(fd)
+ if nremaining < n:
+ raise UnexpectedEndOfStream(f"wants to read {n} bytes but only {nremaining} bytes left")
+
+
+def write_bigsize_int(i: int) -> bytes:
+ assert i >= 0, i
+ if i < 0xfd:
+ return int.to_bytes(i, length=1, byteorder="big", signed=False)
+ elif i < 0x1_0000:
+ return b"\xfd" + int.to_bytes(i, length=2, byteorder="big", signed=False)
+ elif i < 0x1_0000_0000:
+ return b"\xfe" + int.to_bytes(i, length=4, byteorder="big", signed=False)
+ else:
+ return b"\xff" + int.to_bytes(i, length=8, byteorder="big", signed=False)
+
+
+def read_bigsize_int(fd: io.BytesIO) -> Optional[int]:
+ try:
+ first = fd.read(1)[0]
+ except IndexError:
+ return None # end of file
+ if first < 0xfd:
+ return first
+ elif first == 0xfd:
+ buf = fd.read(2)
+ if len(buf) != 2:
+ raise UnexpectedEndOfStream()
+ val = int.from_bytes(buf, byteorder="big", signed=False)
+ if not (0xfd <= val < 0x1_0000):
+ raise FieldEncodingNotMinimal()
+ return val
+ elif first == 0xfe:
+ buf = fd.read(4)
+ if len(buf) != 4:
+ raise UnexpectedEndOfStream()
+ val = int.from_bytes(buf, byteorder="big", signed=False)
+ if not (0x1_0000 <= val < 0x1_0000_0000):
+ raise FieldEncodingNotMinimal()
+ return val
+ elif first == 0xff:
+ buf = fd.read(8)
+ if len(buf) != 8:
+ raise UnexpectedEndOfStream()
+ val = int.from_bytes(buf, byteorder="big", signed=False)
+ if not (0x1_0000_0000 <= val):
+ raise FieldEncodingNotMinimal()
+ return val
+ raise Exception()
+
+
+# TODO: maybe if field_type is not "byte", we could return a list of type_len sized chunks?
+# if field_type is a numeric, we could return a list of ints?
+def _read_primitive_field(
+ *,
+ fd: io.BytesIO,
+ field_type: str,
+ count: Union[int, str]
+) -> Union[bytes, int]:
+ if not fd:
+ raise Exception()
+ if isinstance(count, int):
+ assert count >= 0, f"{count!r} must be non-neg int"
+ elif count == "...":
+ pass
+ else:
+ raise Exception(f"unexpected field count: {count!r}")
+ if count == 0:
+ return b""
+ type_len = None
+ if field_type == 'byte':
+ type_len = 1
+ elif field_type in ('u8', 'u16', 'u32', 'u64'):
+ if field_type == 'u8':
+ type_len = 1
+ elif field_type == 'u16':
+ type_len = 2
+ elif field_type == 'u32':
+ type_len = 4
+ else:
+ assert field_type == 'u64'
+ type_len = 8
+ assert count == 1, count
+ buf = fd.read(type_len)
+ if len(buf) != type_len:
+ raise UnexpectedEndOfStream()
+ return int.from_bytes(buf, byteorder="big", signed=False)
+ elif field_type in ('tu16', 'tu32', 'tu64'):
+ if field_type == 'tu16':
+ type_len = 2
+ elif field_type == 'tu32':
+ type_len = 4
+ else:
+ assert field_type == 'tu64'
+ type_len = 8
+ assert count == 1, count
+ raw = fd.read(type_len)
+ if len(raw) > 0 and raw[0] == 0x00:
+ raise FieldEncodingNotMinimal()
+ return int.from_bytes(raw, byteorder="big", signed=False)
+ elif field_type == 'bigsize':
+ assert count == 1, count
+ val = read_bigsize_int(fd)
+ if val is None:
+ raise UnexpectedEndOfStream()
+ return val
+ elif field_type == 'chain_hash':
+ type_len = 32
+ elif field_type == 'channel_id':
+ type_len = 32
+ elif field_type == 'sha256':
+ type_len = 32
+ elif field_type == 'signature':
+ type_len = 64
+ elif field_type == 'point':
+ type_len = 33
+ elif field_type == 'short_channel_id':
+ type_len = 8
+ elif field_type == 'sciddir_or_pubkey':
+ buf = fd.read(1)
+ if buf[0] in [0, 1]:
+ type_len = 9
+ elif buf[0] in [2, 3]:
+ type_len = 33
+ else:
+ raise Exception(f"invalid sciddir_or_pubkey, prefix byte not in range 0-3")
+ buf += fd.read(type_len - 1)
+ if len(buf) != type_len:
+ raise UnexpectedEndOfStream()
+ return buf
+
+ if count == "...":
+ total_len = -1 # read all
+ else:
+ if type_len is None:
+ raise UnknownMsgFieldType(f"unknown field type: {field_type!r}")
+ total_len = count * type_len
+
+ buf = fd.read(total_len)
+ if total_len >= 0 and len(buf) != total_len:
+ raise UnexpectedEndOfStream()
+ return buf
+
+
+# TODO: maybe for "value" we could accept a list with len "count" of appropriate items
+def _write_primitive_field(
+ *,
+ fd: io.BytesIO,
+ field_type: str,
+ count: Union[int, str],
+ value: Union[bytes, int]
+) -> None:
+ if not fd:
+ raise Exception()
+ if isinstance(count, int):
+ assert count >= 0, f"{count!r} must be non-neg int"
+ elif count == "...":
+ pass
+ else:
+ raise Exception(f"unexpected field count: {count!r}")
+ if count == 0:
+ return
+ type_len = None
+ if field_type == 'byte':
+ type_len = 1
+ elif field_type == 'u8':
+ type_len = 1
+ elif field_type == 'u16':
+ type_len = 2
+ elif field_type == 'u32':
+ type_len = 4
+ elif field_type == 'u64':
+ type_len = 8
+ elif field_type in ('tu16', 'tu32', 'tu64'):
+ if field_type == 'tu16':
+ type_len = 2
+ elif field_type == 'tu32':
+ type_len = 4
+ else:
+ assert field_type == 'tu64'
+ type_len = 8
+ assert count == 1, count
+ if isinstance(value, int):
+ value = int.to_bytes(value, length=type_len, byteorder="big", signed=False)
+ if not isinstance(value, (bytes, bytearray)):
+ raise Exception(f"can only write bytes into fd. got: {value!r}")
+ while len(value) > 0 and value[0] == 0x00:
+ value = value[1:]
+ nbytes_written = fd.write(value)
+ if nbytes_written != len(value):
+ raise Exception(f"tried to write {len(value)} bytes, but only wrote {nbytes_written}!?")
+ return
+ elif field_type == 'bigsize':
+ assert count == 1, count
+ if isinstance(value, int):
+ value = write_bigsize_int(value)
+ if not isinstance(value, (bytes, bytearray)):
+ raise Exception(f"can only write bytes into fd. got: {value!r}")
+ nbytes_written = fd.write(value)
+ if nbytes_written != len(value):
+ raise Exception(f"tried to write {len(value)} bytes, but only wrote {nbytes_written}!?")
+ return
+ elif field_type == 'chain_hash':
+ type_len = 32
+ elif field_type == 'channel_id':
+ type_len = 32
+ elif field_type == 'sha256':
+ type_len = 32
+ elif field_type == 'signature':
+ type_len = 64
+ elif field_type == 'point':
+ type_len = 33
+ elif field_type == 'short_channel_id':
+ type_len = 8
+ elif field_type == 'sciddir_or_pubkey':
+ assert isinstance(value, bytes)
+ if value[0] in [0, 1]:
+ type_len = 9 # short_channel_id
+ elif value[0] in [2, 3]:
+ type_len = 33 # point
+ else:
+ raise Exception(f"invalid sciddir_or_pubkey, prefix byte not in range 0-3")
+ total_len = -1
+ if count != "...":
+ if type_len is None:
+ raise UnknownMsgFieldType(f"unknown field type: {field_type!r}")
+ total_len = count * type_len
+ if isinstance(value, int) and (count == 1 or field_type == 'byte'):
+ value = int.to_bytes(value, length=total_len, byteorder="big", signed=False)
+ if not isinstance(value, (bytes, bytearray)):
+ raise Exception(f"can only write bytes into fd. got: {value!r}")
+ if count != "..." and total_len != len(value):
+ raise UnexpectedFieldSizeForEncoder(f"expected: {total_len}, got {len(value)}")
+ nbytes_written = fd.write(value)
+ if nbytes_written != len(value):
+ raise Exception(f"tried to write {len(value)} bytes, but only wrote {nbytes_written}!?")
+
+
+def _read_tlv_record(*, fd: io.BytesIO) -> Tuple[int, bytes]:
+ if not fd: raise Exception()
+ tlv_type = _read_primitive_field(fd=fd, field_type="bigsize", count=1)
+ tlv_len = _read_primitive_field(fd=fd, field_type="bigsize", count=1)
+ tlv_val = _read_primitive_field(fd=fd, field_type="byte", count=tlv_len)
+ return tlv_type, tlv_val
+
+
+def _write_tlv_record(*, fd: io.BytesIO, tlv_type: int, tlv_val: bytes) -> None:
+ if not fd: raise Exception()
+ tlv_len = len(tlv_val)
+ _write_primitive_field(fd=fd, field_type="bigsize", count=1, value=tlv_type)
+ _write_primitive_field(fd=fd, field_type="bigsize", count=1, value=tlv_len)
+ _write_primitive_field(fd=fd, field_type="byte", count=tlv_len, value=tlv_val)
+
+
+def _resolve_field_count(field_count_str: str, *, vars_dict: Mapping, allow_any=False) -> Union[int, str]:
+ """Returns an evaluated field count, typically an int.
+ If allow_any is True, the return value can be a str with value=="...".
+ """
+ if field_count_str == "":
+ field_count = 1
+ elif field_count_str == "...":
+ if not allow_any:
+ raise Exception("field count is '...' but allow_any is False")
+ return field_count_str
+ else:
+ try:
+ field_count = int(field_count_str)
+ except ValueError:
+ field_count = vars_dict[field_count_str]
+ if isinstance(field_count, (bytes, bytearray)):
+ field_count = int.from_bytes(field_count, byteorder="big")
+ assert isinstance(field_count, int)
+ return field_count
+
+
+def _parse_msgtype_intvalue_for_onion_wire(value: str) -> int:
+ msg_type_int = 0
+ for component in value.split("|"):
+ try:
+ msg_type_int |= int(component)
+ except ValueError:
+ msg_type_int |= OnionFailureCodeMetaFlag[component]
+ return msg_type_int
+
+
+class LNSerializer:
+
+ def __init__(self, *, name: str = 'peer_wire'):
+ # TODO msg_type could be 'int' everywhere...
+ self.msg_scheme_from_type = {} # type: Dict[bytes, List[Sequence[str]]]
+ self.msg_type_from_name = {} # type: Dict[str, bytes]
+
+ self.in_tlv_stream_get_tlv_record_scheme_from_type = {} # type: Dict[str, Dict[int, List[Sequence[str]]]]
+ self.in_tlv_stream_get_record_type_from_name = {} # type: Dict[str, Dict[str, int]]
+ self.in_tlv_stream_get_record_name_from_type = {} # type: Dict[str, Dict[int, str]]
+
+ self.subtypes = {} # type: Dict[str, Dict[str, Sequence[str]]]
+
+ path = os.path.join(os.path.dirname(__file__), "lnwire", name + ".csv")
+ with open(path, newline='') as f:
+ csvreader = csv.reader(f)
+ for row in csvreader:
+ #print(f">>> {row!r}")
+ if row[0] == "msgtype":
+ # msgtype,,[,