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/ +``` + +[![Build Status](https://github.com/spesmilo/electrum/actions/workflows/builds.yml/badge.svg?branch=master)](https://github.com/spesmilo/electrum/actions/workflows/builds.yml) +[![Test coverage statistics](https://coveralls.io/repos/github/spesmilo/electrum/badge.svg?branch=master)](https://coveralls.io/github/spesmilo/electrum?branch=master) +[![Help translate Electrum online](https://d322cqt584bo4o.cloudfront.net/electrum/localized.svg)](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 - sigpath = path + '.asc' - siglink = link + '.asc' - if not os.path.exists(sigpath): - os.system("wget -q %s -O %s" % (siglink, sigpath)) - if not os.path.getsize(sigpath): - os.unlink(sigpath) - 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 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + System Preferences + + + Andreas Nilsson + + + + + category + system + preferences + settings + control center + + + + + Jakub Steiner +Ulisse Perusin + + + + + + + + + + + + + + image/svg+xml + + Preferences + + + Andreas Nilsson + + + + + Lapo Calamandrei, Ulisse Perusin, Jakub Steiner + + + + + + category + system + preferences + settings + control center + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + Lapo Calamandrei + + + + + + + + record + media + + + + + Jakub Steiner + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + Lapo Calamandrei + + + + + + + + record + media + + + + + Jakub Steiner + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + Lapo Calamandrei + + + + + + + + record + media + + + + + Jakub Steiner + + + + + + + + + + + + + + + + + + + + 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.'), + '

', + _('To install this plugin, please enter your plugin authorization password') + ':' +]) + +MSG_CONFLICTING_BACKUP_INSTANCE = _( +"""Another instance of this wallet (same seed) has an open channel with the same remote node. If you create this channel, you will not be able to use both wallets at the same time. + +Are you sure?""" +) + +MSG_LN_EXPLAIN_SCB_BACKUPS = "".join([ + _("Channel backups can be imported in another instance of the same wallet."), " ", + _("In the Electrum mobile app, use the 'Send' button to scan this QR code."), " ", + "\n\n", + _("Please note that channel backups cannot be used to restore your channels."), " ", + _("If you lose your wallet file, the only thing you can do with a backup is to request your channel to be closed, so that your funds will be sent on-chain."), +]) + +MSG_CAPITAL_GAINS = _( +"""This summary covers only on-chain transactions (no lightning!). Capital gains are computed by attaching an acquisition price to each UTXO in the wallet, and uses the order of blockchain events (not FIFO).""" +) + +MSG_NON_TRAMPOLINE_CHANNEL_FROZEN_WITHOUT_GOSSIP = _( +"""This channel is with a non-trampoline node; it cannot be used if trampoline is enabled. +If you want to keep using this channel, you need to disable trampoline routing in your preferences.""" +) + +MSG_FREEZE_ADDRESS = _("When you freeze an address, the funds in that address will not be used for sending bitcoins.") +MSG_FREEZE_COIN = _("When you freeze a coin, it will not be used for sending bitcoins.") + +MSG_FORWARD_SWAP_FUNDING_MEMPOOL = ( + _('Your funding transaction has been broadcast.') + " " + + _("Please remain online until the funding transaction is confirmed.") + "\n\n" + + _('The swap will be finalized once your transaction is confirmed.') + " " + + _("After the funding transaction is mined, the server will reveal the preimage needed to " + "fulfill the pending received lightning HTLCs. The HTLCs expire in {} blocks. " + "You will need to be online after the funding transaction is confirmed but before the HTLCs expire, " + "to claim your money. If you go offline for several days while the swap is pending, " + "you risk losing the swap amount!").format(MIN_FINAL_CLTV_DELTA_FOR_CLIENT) +) + +MSG_REVERSE_SWAP_FUNDING_MEMPOOL = ( + _('The funding transaction has been detected.') + " " + + _('Your claiming transaction will be broadcast when the funding transaction is confirmed.') + " " + + _("If you go offline before broadcasting the claiming transaction and let the swap time out, " + "you will not get back the already pre-paid mining fees.") +) + +MSG_FORCE_CLOSE_WARNING = ( + _('You will need to come back online after the commitment transaction is confirmed, in order to broadcast second-stage htlc transactions.') + ' ' + + _('If you remain offline for more than {} blocks, your channel counterparty will be able to sweep those funds.') +) + +MSG_FORWARD_SWAP_WARNING = ( + _('You will need to come back online after the funding transaction is confirmed, in order to settle the swap.') + ' ' + + _('If you remain offline for more than {} blocks, your channel will be force closed and you might lose the funds you sent in the swap.') +) + +MSG_REVERSE_SWAP_WARNING = ( + _('You will need to come back online after the funding transaction is confirmed, in order to settle the swap.') + ' ' + + _('If you remain offline for more than {} blocks, the swap will be cancelled and you will lose the prepaid mining fees.') +) + +MSG_LN_UTXO_RESERVE = ( + _("You do not have enough on-chain funds to protect your Lightning channels.") + ' ' + + _("You should have at least {} on-chain in order to be able to sweep channel outputs.") +) + +# not to be translated +MSG_TERMS_OF_USE = ( +"""1. Electrum is distributed under the MIT licence by Electrum Technologies GmbH. Most notably, this means that the Electrum software is provided as is, and that it comes without warranty. + +2. We are neither a bank nor a financial service provider. In addition, we do not store user account data, and we are not an intermediary in the interaction between our software and the Bitcoin blockchain. Therefore, we do not have the possibility to freeze funds or to undo a fraudulent transaction. + +3. We do not provide private user support. All issue resolutions are public, and take place on Github or public forums. If someone posing as 'Electrum support' proposes to help you via a private channel, this person is most likely an imposter trying to steal your bitcoins.""" +) +TERMS_OF_USE_LATEST_VERSION : int = 1 # bump this if we want users re-prompted due to changes + + +MSG_CONNECTMODE_AUTOCONNECT = _('Auto-connect') +MSG_CONNECTMODE_MANUAL = _('Manual server selection') +MSG_CONNECTMODE_ONESERVER = _('Connect only to a single server') + +MSG_CONNECTMODE_SERVER_HELP = _( + "Electrum connects to a unique server in order to receive your transaction history. " + "This server will learn your wallet addresses." +) +MSG_CONNECTMODE_NODES_HELP = _( + "In addition to your history server, Electrum will try to maintain connections with ~10 extra servers, in order to download block headers and find out the longest blockchain. " + "These servers are only used for block header notifications and fee estimates; they do not learn your wallet addresses. " + "Getting block headers from multiple sources is useful to detect lagging servers and forks. " + "Fork detection is security-critical for determining number of confirmations." +) + +MSG_CONNECTMODE_AUTOCONNECT_HELP = _( + "Electrum will always use a history server that is on the longest blockchain. " + "If your current server is unresponsive or lagging, Electrum will switch to another server." +) + +MSG_CONNECTMODE_MANUAL_HELP = _( + "Electrum will stay with the server you selected. It will warn you if your server is lagging." +) + +MSG_CONNECTMODE_ONESERVER_HELP = _( + "Electrum will stay with the server you selected, and it will not connect to additional nodes. " + "This will disable fork detection. " + "This mode is only intended for connecting to your own fully trusted server. " + "Using this option on a public server is a security risk and is discouraged." +) + +MSG_SUBMARINE_PAYMENT_HELP_TEXT = ''.join(( + _("Submarine Payments use a reverse submarine swap to do on-chain transactions directly " + "from your lightning balance."), '\n\n', + _("Submarine Payments happen in two stages. In the first stage, your wallet sends a lightning " + "payment to the submarine swap provider. The swap provider will lock funds to a " + "funding output in an on-chain transaction (the funding transaction)."), '\n', + _("Once the funding transaction has one confirmation, your wallet will broadcast a claim " + "transaction as the second stage of the payment. This claim transaction spends the funding " + "output to the payee's address."), '\n\n', + _("Warning:"), '\n', + _('The funding transaction is not visible to the payee. They will only see a pending ' + 'transaction in the mempool after your wallet broadcasts the claim transaction. ' + 'Since confirmation of the funding transaction can take over 30 minutes, avoid using ' + 'Submarine Payments when the payee expects to see the transaction within a limited ' + 'time frame (e.g., an online shop checkout). Use a regular on-chain payment instead.'), +)) + +MSG_RELAYFEE = ' '.join([ + _("This transaction requires a higher fee, or it will not be propagated by your current server."), + _("Try to raise your transaction fee, or use a server with a lower relay fee.") +]) diff --git a/electrum/gui/qml/__init__.py b/electrum/gui/qml/__init__.py new file mode 100644 index 000000000000..8f7b6689d000 --- /dev/null +++ b/electrum/gui/qml/__init__.py @@ -0,0 +1,110 @@ +import os +import signal +import sys +import threading +from typing import TYPE_CHECKING + +try: + import PyQt6 +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 + +try: + import PyQt6.QtQml +except Exception as e: + from electrum import GuiImportError + raise GuiImportError( + "Error: Could not import PyQt6.QtQml. On Linux systems, " + "you may try 'sudo apt-get install python3-pyqt6.qtquick'") from e + +from PyQt6.QtCore import (Qt, QCoreApplication, QLocale, QTimer, QT_VERSION_STR, PYQT_VERSION_STR) +from PyQt6.QtGui import QGuiApplication + +from electrum.plugin import run_hook +from electrum.util import profiler +from electrum.logging import Logger +from electrum.gui import BaseElectrumGui +from electrum.gui.common_qt.i18n import ElectrumTranslator + + +if TYPE_CHECKING: + from electrum.daemon import Daemon + from electrum.simple_config import SimpleConfig + from electrum.plugin import Plugins + +from .qeapp import ElectrumQmlApplication, Exception_Hook + + +class ElectrumGui(BaseElectrumGui, Logger): + @profiler + def __init__(self, config: 'SimpleConfig', daemon: 'Daemon', plugins: 'Plugins'): + BaseElectrumGui.__init__(self, config=config, daemon=daemon, plugins=plugins) + Logger.__init__(self) + + # uncomment to debug plugin and import tracing + # os.environ['QML_IMPORT_TRACE'] = '1' + # os.environ['QT_DEBUG_PLUGINS'] = '1' + + os.environ['QT_ANDROID_DISABLE_ACCESSIBILITY'] = '1' + + # set default locale to en_GB. This is for l10n (e.g. number formatting, number input etc), + # but not for i18n, which is handled by the Translator + # this can be removed once the backend wallet is fully l10n aware + QLocale.setDefault(QLocale('en_GB')) + + self.logger.info(f"Qml GUI starting up... Qt={QT_VERSION_STR}, PyQt={PYQT_VERSION_STR}") + self.logger.info("CWD=%s" % os.getcwd()) + # 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(Qt, "AA_ShareOpenGLContexts"): + QCoreApplication.setAttribute(Qt.AA_ShareOpenGLContexts) + if hasattr(QGuiApplication, 'setDesktopFileName'): + QGuiApplication.setDesktopFileName('electrum') + + if "QT_QUICK_CONTROLS_STYLE" not in os.environ: + os.environ["QT_QUICK_CONTROLS_STYLE"] = "Material" + + self.gui_thread = threading.current_thread() + self.app = ElectrumQmlApplication(sys.argv, config=config, daemon=daemon, plugins=plugins) + self.translator = ElectrumTranslator() + self.app.installTranslator(self.translator) + + # timer + self.timer = QTimer(self.app) + self.timer.setSingleShot(False) + self.timer.setInterval(500) # msec + self.timer.timeout.connect(lambda: None) # periodically enter python scope + + # hook for crash reporter + Exception_Hook.maybe_setup(slot=self.app.appController.crash) + + # Initialize any QML plugins + run_hook('init_qml', self.app) + self.app.engine.load('electrum/gui/qml/components/main.qml') + + def close(self): + self.app.quit() + + def main(self): + if not self.app._valid: + return + + self.timer.start() + signal.signal(signal.SIGINT, lambda *args: self._handle_sigint()) + + self.logger.info('Entering main loop') + self.app.exec() + + def _handle_sigint(self): + self.app.appController.wantClose = True + self.stop() + + def stop(self): + self.logger.info('closing GUI') + self.app.quit() diff --git a/electrum/gui/qml/android_res/layout/scanner_layout.xml b/electrum/gui/qml/android_res/layout/scanner_layout.xml new file mode 100644 index 000000000000..4b2023500b56 --- /dev/null +++ b/electrum/gui/qml/android_res/layout/scanner_layout.xml @@ -0,0 +1,36 @@ + + + + + + + + +