Merge bitcoin-core/HWI#786: fix usage link
What changed, and why it matters
This commit is a large repository initialization or merge that adds the entire Bitcoin Core Hardware Wallet Interface (HWI) codebase, documentation, CI workflows, and build scripts. The stated purpose of the merged pull request is to fix a broken documentation link. There are no code changes that introduce or fix a security vulnerability.
No security action required. This is a repository seed/initialization merge with a minor documentation link fix. Reviewers may optionally verify the corrected documentation URL renders correctly on ReadTheDocs.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The commit ccb986b2846768c22798060f218733e2cd4495a4 merges PR #786 titled ‘fix usage link’. The diff shows the addition of 263 files (+41841 lines) representing the full HWI project source tree, including Python library code, device drivers, documentation, CI/GitHub Actions workflows, Dockerfiles, build scripts, and tests. The actual ‘fix usage link’ change is a documentation hyperlink correction (from a .rst extension to .html in README.md). No runtime code, cryptographic logic, or privilege boundaries are modified in a security-relevant way.
Changed components
Inspect captured patch +41841 / −0
diff --git a/.flake8 b/.flake8
new file mode 100644
index 0000000..3d38c0f
--- /dev/null
+++ b/.flake8
@@ -0,0 +1,4 @@
+[flake8]
+exclude = *.pyc,__pycache__,hwilib/devices/ledger_bitcoin/,hwilib/devices/btchip,hwilib/devices/ckcc/,hwilib/devices/jadepy/,hwilib/devices/trezorlib/,test/work/,hwilib/ui,hwilib/devices/bitbox02_lib
+ignore = E261,E302,E305,E501,E722,W5,E231
+per-file-ignores = setup.py:E122
diff --git a/.github/actions/build-bitcoind/action.yml b/.github/actions/build-bitcoind/action.yml
new file mode 100644
index 0000000..e4befd4
--- /dev/null
+++ b/.github/actions/build-bitcoind/action.yml
@@ -0,0 +1,37 @@
+name: Build bitcoind
+description: Build bitcoind with depends.
+runs:
+ using: composite
+ steps:
+ - shell: bash
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y ccache
+ echo "CCACHE_DIR=${{ runner.temp }}/ccache" >> "$GITHUB_ENV"
+
+ - uses: actions/cache/restore@v4
+ id: ccache-cache
+ with:
+ path: ${{ env.CCACHE_DIR }}
+ key: ${{ github.job }}-ccache-${{ github.run_id }}
+ restore-keys: ${{ github.job }}-ccache-
+
+ - shell: bash
+ env:
+ CCACHE_MAXSIZE: '20M'
+ run: |
+ ccache --zero-stats
+ cd test; ./setup_environment.sh --bitcoind; cd ..
+ ccache --show-stats --verbose
+ tar -czf bitcoind.tar.gz test/work/bitcoin/build/bin/bitcoind
+
+ - uses: actions/cache/save@v4
+ if: github.event_name != 'pull_request' && steps.ccache-cache.outputs.cache-hit != 'true'
+ with:
+ path: ${{ env.CCACHE_DIR }}
+ key: ${{ github.job }}-ccache-${{ github.run_id }}
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: bitcoind
+ path: bitcoind.tar.gz
diff --git a/.github/actions/build-sim/action.yml b/.github/actions/build-sim/action.yml
new file mode 100644
index 0000000..2968a9f
--- /dev/null
+++ b/.github/actions/build-sim/action.yml
@@ -0,0 +1,53 @@
+name: Build sim
+description: Build device simulator(s)
+inputs:
+ name:
+ description: Device name from matrix (e.g. trezor-1, coldcard, jade)
+ required: true
+ archive:
+ description: Archive base name (e.g. trezor-firmware)
+ required: true
+ paths:
+ description: Space-separated paths to include in the archive
+ required: true
+runs:
+ using: composite
+ steps:
+ - name: Install dependencies
+ shell: bash
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y gcc-arm-linux-gnueabihf libsdl2-image-dev libslirp-dev libpcsclite-dev ninja-build libltdl-dev
+ pip install poetry uv
+ wget https://github.com/protocolbuffers/protobuf/releases/download/v22.0/protoc-22.0-linux-x86_64.zip
+ sudo unzip protoc-22.0-linux-x86_64.zip -d /usr/local
+ protoc --version
+
+ - name: Build simulator
+ shell: bash
+ run: |
+ set -euxo pipefail
+ git config --global user.email 'ci@ci.com'
+ git config --global user.name 'ci'
+ git config --global --add safe.directory "$GITHUB_WORKSPACE"
+ # Rewrite lwip URLs to github mirror to avoid stalling issue
+ git config --global --add url."https://github.com/lwip-tcpip/lwip.git".insteadOf "https://git.savannah.gnu.org/r/lwip.git"
+ git config --global --add url."https://github.com/lwip-tcpip/lwip.git".insteadOf "https://git.savannah.nongnu.org/git/lwip.git"
+ cd test
+ ./setup_environment.sh --"${{ inputs.name }}"
+ cd ..
+
+ # Remove .git directories to shrink the archive
+ for p in ${{ inputs.paths }}; do
+ # Skip speculos: it uses setuptools_scm which requires .git for version detection
+ if [[ "$p" != *speculos* ]]; then
+ find "$p" -name ".git" -type d -exec rm -rf {} + 2>/dev/null || true
+ fi
+ done
+ tar -czf "${{ inputs.archive }}.tar.gz" ${{ inputs.paths }}
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: ${{ inputs.name }}-sim
+ path: ${{ inputs.archive }}.tar.gz
+
diff --git a/.github/actions/install-sim/action.yml b/.github/actions/install-sim/action.yml
new file mode 100644
index 0000000..2615849
--- /dev/null
+++ b/.github/actions/install-sim/action.yml
@@ -0,0 +1,86 @@
+name: Install Sim
+description: Fetch and install simulator.
+inputs:
+ device:
+ description: A tested device name
+ required: true
+runs:
+ using: composite
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ name: ${{ inputs.device == 'ledger-legacy' && 'ledger' || inputs.device }}-sim
+
+ - if: startsWith(inputs.device, 'trezor-')
+ shell: bash
+ run: |
+ apt-get update
+ apt-get install -y libsdl2-image-2.0-0 libusb-1.0-0
+ tar -xvf trezor-firmware.tar.gz
+
+ - if: inputs.device == 'coldcard'
+ shell: bash
+ run: |
+ apt-get update
+ apt-get install -y libpcsclite-dev libusb-1.0-0 swig
+
+ # Extract the archive - this includes the full firmware directory
+ tar -xvf coldcard-firmware.tar.gz
+
+ poetry run pip install -r test/work/firmware/requirements.txt
+ pip install -r test/work/firmware/requirements.txt
+ poetry run pip install pysdl2-dll
+ pip install pysdl2-dll
+
+ - if: inputs.device == 'bitbox01'
+ shell: bash
+ run: |
+ apt-get update
+ apt-get install -y libusb-1.0-0
+ tar -xvf mcu.tar.gz
+
+ - if: inputs.device == 'bitbox02'
+ shell: bash
+ run: |
+ apt-get update
+ apt-get install -y libusb-1.0-0 docker.io
+ tar -xvf bitbox02.tar.gz
+
+ - if: inputs.device == 'jade'
+ shell: bash
+ run: |
+ apt-get update
+ apt-get install -y libgcrypt20 libsdl2-dev libslirp0 libusb-1.0-0
+ tar -xvf jade.tar.gz
+
+ - if: startsWith(inputs.device, 'ledger')
+ shell: bash
+ run: |
+ apt-get update
+ apt-get install -y libusb-1.0-0 qemu-user-static
+ tar -xvf speculos.tar.gz
+ poetry run pip install -e test/work/speculos
+ pip install -e test/work/speculos
+
+ - if: startsWith(inputs.device, 'ledger')
+ uses: actions/download-artifact@v4
+ with:
+ name: ${{ inputs.device == 'ledger-legacy' && 'ledger_app_legacy' || 'ledger_app' }}
+
+
+ - if: inputs.device == 'ledger'
+ shell: bash
+ run: |
+ mv app.elf test/work/speculos/apps/btc-test.elf
+
+ - if: inputs.device == 'ledger-legacy'
+ shell: bash
+ run: |
+ mv app.elf test/work/speculos/apps/btc-test-legacy.elf
+
+ - if: inputs.device == 'keepkey'
+ shell: bash
+ run: |
+ apt-get update
+ apt-get install -y libusb-1.0-0
+ tar -xvf keepkey-firmware.tar.gz
diff --git a/.github/actions/test-device/action.yml b/.github/actions/test-device/action.yml
new file mode 100644
index 0000000..2fc72be
--- /dev/null
+++ b/.github/actions/test-device/action.yml
@@ -0,0 +1,31 @@
+name: Test device
+description: Run tests for one device type.
+runs:
+ using: composite
+ steps:
+ - shell: bash
+ run: |
+ pip install poetry
+ poetry install
+
+ - uses: actions/download-artifact@v4
+ with:
+ name: bitcoind
+
+ - shell: bash
+ run: |
+ tar -xvf bitcoind.tar.gz
+
+ - uses: ./.github/actions/install-sim
+ with:
+ device: ${{ matrix.device }}
+
+ - name: Run tests
+ shell: bash
+ run: |
+ cd test; poetry run ./run_tests.py --${{ matrix.device }} --interface=${{ matrix.test.interface }} --device-only; cd ..
+
+ - if: failure()
+ shell: bash
+ run: |
+ tail -v -n +1 test/*.std*
diff --git a/.github/actions/test-dist/action.yml b/.github/actions/test-dist/action.yml
new file mode 100644
index 0000000..6675fe3
--- /dev/null
+++ b/.github/actions/test-dist/action.yml
@@ -0,0 +1,67 @@
+name: Test dist
+description: Run dist tests for one device type.
+runs:
+ using: composite
+ steps:
+ - shell: bash
+ run: |
+ pip install poetry
+
+ - uses: actions/download-artifact@v4
+ with:
+ name: dist
+ path: dist/
+
+ - name: Install (Wheel)
+ if: matrix.test.script == 'Wheel'
+ shell: bash
+ run: |
+ pip install dist/*.whl
+
+ - name: Install (Sdist)
+ if: matrix.test.script == 'Sdist'
+ shell: bash
+ run: |
+ pip install $(find dist -name "*.tar.gz" -a -not -name "*linux*")
+
+ - name: Install (Bindist)
+ if: matrix.test.script == 'Bindist'
+ shell: bash
+ run: |
+ poetry install; cd dist; tar -xvf hwi*linux*.tar.gz; cd ..
+
+ - uses: actions/download-artifact@v4
+ with:
+ name: bitcoind
+
+ - shell: bash
+ run: |
+ tar -xvf bitcoind.tar.gz
+
+ - uses: ./.github/actions/install-sim
+ with:
+ device: ${{ matrix.device }}
+
+ - name: Run tests (Wheel)
+ if: matrix.test.script == 'Wheel'
+ shell: bash
+ run: |
+ cd test; ./run_tests.py --${{ matrix.device }} --interface=cli --device-only; cd ..
+
+ - name: Run tests (Sdist)
+ if: matrix.test.script == 'Sdist'
+ shell: bash
+ run: |
+ cd test; ./run_tests.py --${{ matrix.device }} --interface=cli --device-only; cd ..
+
+ - name: Run tests (Bindist)
+ if: matrix.test.script == 'Bindist'
+ shell: bash
+ run: |
+ cd test; poetry run ./run_tests.py --${{ matrix.device }} --interface=bindist --device-only; cd ..
+
+ - if: failure()
+ shell: bash
+ run: |
+ tail -v -n +1 test/*.std*
+
diff --git a/.github/sim-build-map.json b/.github/sim-build-map.json
new file mode 100644
index 0000000..c5ad49f
--- /dev/null
+++ b/.github/sim-build-map.json
@@ -0,0 +1,22 @@
+{
+ "trezor": [
+ { "name": "trezor-1", "archive": "trezor-firmware", "paths": "test/work/trezor-firmware" },
+ { "name": "trezor-t", "archive": "trezor-firmware", "paths": "test/work/trezor-firmware" }
+ ],
+ "coldcard": [
+ { "name": "coldcard", "archive": "coldcard-firmware", "paths": "test/work/firmware" }
+ ],
+ "bitbox": [
+ { "name": "bitbox01", "archive": "mcu", "paths": "test/work/mcu" },
+ { "name": "bitbox02", "archive": "bitbox02", "paths": "test/work/bitbox02-firmware/build-build-noasan/bin/simulator" }
+ ],
+ "jade": [
+ { "name": "jade", "archive": "jade", "paths": "test/work/jade/simulator" }
+ ],
+ "ledger": [
+ { "name": "ledger", "archive": "speculos", "paths": "test/work/speculos" }
+ ],
+ "keepkey": [
+ { "name": "keepkey", "archive": "keepkey-firmware", "paths": "test/work/keepkey-firmware/bin" }
+ ]
+}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..10e2c51
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,245 @@
+name: CI
+on:
+ # See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#pull_request.
+ pull_request:
+ # See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#push.
+ push:
+ branches:
+ - '**'
+ tags-ignore:
+ - '**'
+
+concurrency:
+ group: ${{ github.event_name != 'pull_request' && github.run_id || github.ref }}
+ cancel-in-progress: true
+
+env:
+ LC_ALL: 'C.UTF-8'
+ LANG: 'C.UTF-8'
+ LANGUAGE: 'C.UTF-8'
+
+jobs:
+ non-device-tests:
+ name: Non-device tests
+ runs-on: ubuntu-latest
+ container: python:3.12
+ steps:
+ - uses: actions/checkout@v4
+
+ - run: |
+ pip install poetry
+ poetry install
+
+ - name: Run tests
+ run: |
+ cd test; poetry run ./run_tests.py; cd ..
+
+ lint:
+ name: lint
+ runs-on: ubuntu-latest
+ container: python:3.12
+ steps:
+ - uses: actions/checkout@v4
+
+ - run: |
+ pip install poetry
+ poetry install
+
+ - run: |
+ poetry run flake8
+
+
+ wine-builder:
+ name: Wine builder
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: contrib/build-wine.Dockerfile
+ tags: build-wine-container
+ load: true
+
+ - run: >
+ docker run --rm \
+ --volume ${{ github.workspace }}:${{ github.workspace }} \
+ --workdir ${{ github.workspace }} \
+ build-wine-container bash -c "
+ git config --global --add safe.directory ${{ github.workspace }}
+ contrib/build_wine.sh
+ find dist -type f -exec sha256sum {} \;
+ "
+
+ dist-builder:
+ name: Dist builder
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: contrib/build.Dockerfile
+ tags: build-container
+ load: true
+
+ - run: >
+ docker run --rm \
+ --volume ${{ github.workspace }}:${{ github.workspace }} \
+ --workdir ${{ github.workspace }} \
+ build-container bash -c "
+ git config --global --add safe.directory ${{ github.workspace }}
+ contrib/build_bin.sh
+ contrib/build_dist.sh
+ find dist -type f -exec sha256sum {} \;
+ "
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: dist
+ path: dist/
+
+ prepare-sim-matrices:
+ name: Prepare sim matrices
+ uses: ./.github/workflows/prepare-sim-matrices.yml
+
+ sim-builder-trezor:
+ name: Trezor sim builder
+ needs: prepare-sim-matrices
+ uses: ./.github/workflows/sim-builder.yml
+ with:
+ sim: trezor
+ include: ${{ needs.prepare-sim-matrices.outputs.trezor }}
+ runs-on: ubuntu-latest
+
+ sim-builder-coldcard:
+ name: Coldcard sim builder
+ needs: prepare-sim-matrices
+ uses: ./.github/workflows/sim-builder.yml
+ with:
+ sim: coldcard
+ include: ${{ needs.prepare-sim-matrices.outputs.coldcard }}
+ runs-on: ubuntu-latest
+
+ sim-builder-bitbox:
+ name: Bitbox sim builder
+ needs: prepare-sim-matrices
+ uses: ./.github/workflows/sim-builder.yml
+ with:
+ sim: bitbox
+ include: ${{ needs.prepare-sim-matrices.outputs.bitbox }}
+ runs-on: ubuntu-latest
+
+ sim-builder-jade:
+ name: Jade sim builder
+ needs: prepare-sim-matrices
+ uses: ./.github/workflows/sim-builder.yml
+ with:
+ sim: jade
+ include: ${{ needs.prepare-sim-matrices.outputs.jade }}
+ runs-on: ubuntu-latest
+
+ sim-builder-ledger:
+ name: Ledger sim builder
+ needs: prepare-sim-matrices
+ uses: ./.github/workflows/sim-builder.yml
+ with:
+ sim: ledger
+ include: ${{ needs.prepare-sim-matrices.outputs.ledger }}
+ runs-on: ubuntu-latest
+
+ sim-builder-keepkey:
+ name: Keepkey sim builder
+ needs: prepare-sim-matrices
+ uses: ./.github/workflows/sim-builder.yml
+ with:
+ sim: keepkey
+ include: ${{ needs.prepare-sim-matrices.outputs.keepkey }}
+ runs-on: ubuntu-latest
+
+ ledger-legacy-app-builder:
+ name: Ledger Bitcoin Legacy App builder
+ uses: ./.github/workflows/ledger-legacy-app-builder.yml
+ with:
+ runs-on: ubuntu-latest
+
+ ledger-app-builder:
+ name: Ledger Bitcoin App builder
+ uses: ./.github/workflows/ledger-app-builder.yml
+ with:
+ runs-on: ubuntu-latest
+
+ bitcoind-builder:
+ name: bitcoind builder
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: ./.github/actions/build-bitcoind
+
+ test-trezor-1:
+ uses: ./.github/workflows/device-test.yml
+ needs: [sim-builder-trezor, bitcoind-builder, dist-builder]
+ with:
+ device: trezor-1
+ runs-on: ubuntu-latest
+
+ test-trezor-t:
+ uses: ./.github/workflows/device-test.yml
+ needs: [sim-builder-trezor, bitcoind-builder, dist-builder]
+ with:
+ device: trezor-t
+ runs-on: ubuntu-latest
+
+ test-ledger-legacy:
+ uses: ./.github/workflows/device-test.yml
+ needs: [sim-builder-ledger, ledger-legacy-app-builder, bitcoind-builder, dist-builder]
+ with:
+ device: ledger-legacy
+ runs-on: ubuntu-latest
+
+ test-ledger:
+ uses: ./.github/workflows/device-test.yml
+ needs: [sim-builder-ledger, ledger-app-builder, bitcoind-builder, dist-builder]
+ with:
+ device: ledger
+ runs-on: ubuntu-latest
+
+ test-coldcard:
+ uses: ./.github/workflows/device-test.yml
+ needs: [sim-builder-coldcard, bitcoind-builder, dist-builder]
+ with:
+ device: coldcard
+ runs-on: ubuntu-latest
+
+ test-bitbox01:
+ uses: ./.github/workflows/device-test.yml
+ needs: [sim-builder-bitbox, bitcoind-builder, dist-builder]
+ with:
+ device: bitbox01
+ runs-on: ubuntu-latest
+
+ test-bitbox02:
+ uses: ./.github/workflows/device-test.yml
+ needs: [sim-builder-bitbox, bitcoind-builder, dist-builder]
+ with:
+ device: bitbox02
+ runs-on: ubuntu-latest
+
+ test-jade:
+ uses: ./.github/workflows/device-test.yml
+ needs: [sim-builder-jade, bitcoind-builder, dist-builder]
+ with:
+ device: jade
+ runs-on: ubuntu-latest
+
+ test-keepkey:
+ uses: ./.github/workflows/device-test.yml
+ needs: [sim-builder-keepkey, bitcoind-builder, dist-builder]
+ with:
+ device: keepkey
+ runs-on: ubuntu-latest
+
diff --git a/.github/workflows/device-test.yml b/.github/workflows/device-test.yml
new file mode 100644
index 0000000..5232314
--- /dev/null
+++ b/.github/workflows/device-test.yml
@@ -0,0 +1,39 @@
+name: Device Test Template
+on:
+ workflow_call:
+ inputs:
+ device:
+ required: true
+ type: string
+ runs-on:
+ required: false
+ type: string
+ default: ubuntu-latest
+
+jobs:
+ test-device:
+ name: Python ${{ matrix.python-version }} ${{ matrix.device }} ${{ matrix.test.script || matrix.test.interface }}
+ runs-on: ${{ inputs.runs-on }}
+ timeout-minutes: 45
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ['3.9', '3.10', '3.11', '3.12']
+ device:
+ - ${{ inputs.device }}
+ test:
+ - {interface: 'library'}
+ - {interface: 'cli'}
+ - {interface: 'stdin'}
+ - {script: 'Wheel'}
+ - {script: 'Sdist'}
+ - {script: 'Bindist'}
+ container: python:${{ matrix.python-version }}
+ steps:
+ - uses: actions/checkout@v4
+ - name: run test
+ if: ${{ matrix.test.interface }}
+ uses: ./.github/actions/test-device
+ - name: run dist test
+ if: ${{ matrix.test.script }}
+ uses: ./.github/actions/test-dist
diff --git a/.github/workflows/ledger-app-builder.yml b/.github/workflows/ledger-app-builder.yml
new file mode 100644
index 0000000..d873a24
--- /dev/null
+++ b/.github/workflows/ledger-app-builder.yml
@@ -0,0 +1,25 @@
+name: Ledger Nano X App Builder
+on:
+ workflow_call:
+ inputs:
+ runs-on:
+ required: false
+ type: string
+ default: ubuntu-latest
+
+jobs:
+ build:
+ name: Build Bitcoin App
+ runs-on: ${{ inputs.runs-on }}
+ # Pin to 4.23.0 for SDK v25.9.0 compatibility with Speculos v0.25.10
+ container: ghcr.io/ledgerhq/ledger-app-builder/ledger-app-builder:4.23.0
+ steps:
+ - run: |
+ # Pin to v2.4.1 - last version that worked with HWI CI (PR #795 merged Sept 2025)
+ git clone --branch 2.4.1 --depth 1 https://github.com/LedgerHQ/app-bitcoin-new.git
+ cd app-bitcoin-new
+ make DEBUG=1 BOLOS_SDK=$NANOX_SDK
+ - uses: actions/upload-artifact@v4
+ with:
+ name: ledger_app
+ path: app-bitcoin-new/bin/app.elf
diff --git a/.github/workflows/ledger-legacy-app-builder.yml b/.github/workflows/ledger-legacy-app-builder.yml
new file mode 100644
index 0000000..aac5afb
--- /dev/null
+++ b/.github/workflows/ledger-legacy-app-builder.yml
@@ -0,0 +1,25 @@
+name: Ledger App Builder
+on:
+ workflow_call:
+ inputs:
+ runs-on:
+ required: false
+ type: string
+ default: ubuntu-latest
+
+jobs:
+ build:
+ name: Build Bitcoin Legacy App
+ runs-on: ${{ inputs.runs-on }}
+ # Pin to 4.23.0 for SDK v25.9.0 compatibility with Speculos v0.25.10
+ container: ghcr.io/ledgerhq/ledger-app-builder/ledger-app-builder:4.23.0
+ steps:
+ - run: |
+ # Pin to legacy-1.6.6 HEAD commit for reproducibility
+ git clone --depth 1 https://github.com/LedgerHQ/app-bitcoin.git -b legacy-1.6.6
+ cd app-bitcoin
+ make DEBUG=1 BOLOS_SDK=$NANOSP_SDK
+ - uses: actions/upload-artifact@v4
+ with:
+ name: ledger_app_legacy
+ path: app-bitcoin/bin/app.elf
diff --git a/.github/workflows/prepare-sim-matrices.yml b/.github/workflows/prepare-sim-matrices.yml
new file mode 100644
index 0000000..4f2d718
--- /dev/null
+++ b/.github/workflows/prepare-sim-matrices.yml
@@ -0,0 +1,50 @@
+name: Prepare Sim Matrices
+on:
+ workflow_call:
+ outputs:
+ trezor:
+ description: JSON include array for trezor
+ value: ${{ jobs.prepare.outputs.trezor }}
+ coldcard:
+ description: JSON include array for coldcard
+ value: ${{ jobs.prepare.outputs.coldcard }}
+ bitbox:
+ description: JSON include array for bitbox
+ value: ${{ jobs.prepare.outputs.bitbox }}
+ jade:
+ description: JSON include array for jade
+ value: ${{ jobs.prepare.outputs.jade }}
+ ledger:
+ description: JSON include array for ledger
+ value: ${{ jobs.prepare.outputs.ledger }}
+ keepkey:
+ description: JSON include array for keepkey
+ value: ${{ jobs.prepare.outputs.keepkey }}
+
+jobs:
+ prepare:
+ name: Prepare sim matrices
+ runs-on: ubuntu-latest
+ outputs:
+ trezor: ${{ steps.gen.outputs.trezor }}
+ coldcard: ${{ steps.gen.outputs.coldcard }}
+ bitbox: ${{ steps.gen.outputs.bitbox }}
+ jade: ${{ steps.gen.outputs.jade }}
+ ledger: ${{ steps.gen.outputs.ledger }}
+ keepkey: ${{ steps.gen.outputs.keepkey }}
+ steps:
+ - uses: actions/checkout@v4
+ - id: gen
+ shell: bash
+ run: |
+ set -euo pipefail
+ sudo apt-get install -y jq
+ map_file=".github/sim-build-map.json"
+ for sim in trezor coldcard bitbox jade ledger keepkey; do
+ include=$(jq -c --arg s "$sim" '.[$s]' "$map_file")
+ if [[ -z "$include" || "$include" == "null" ]]; then
+ echo "Missing entry for $sim in $map_file" >&2
+ exit 1
+ fi
+ echo "$sim=$include" >> "$GITHUB_OUTPUT"
+ done
diff --git a/.github/workflows/sim-builder.yml b/.github/workflows/sim-builder.yml
new file mode 100644
index 0000000..708ec61
--- /dev/null
+++ b/.github/workflows/sim-builder.yml
@@ -0,0 +1,30 @@
+name: Simulator Builder
+on:
+ workflow_call:
+ inputs:
+ sim:
+ required: true
+ type: string
+ include:
+ required: true # Precomputed JSON array for matrix.include
+ type: string
+ runs-on:
+ required: false
+ type: string
+ default: ubuntu-latest
+
+jobs:
+ build-simulator:
+ name: "Sim builder: ${{ matrix.name }}"
+ runs-on: ${{ inputs.runs-on }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include: ${{ fromJSON(inputs.include) }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: ./.github/actions/build-sim
+ with:
+ name: ${{ matrix.name }}
+ archive: ${{ matrix.archive }}
+ paths: ${{ matrix.paths }}
diff --git a/.github/workflows/type-check.yml b/.github/workflows/type-check.yml
new file mode 100644
index 0000000..09a04c0
--- /dev/null
+++ b/.github/workflows/type-check.yml
@@ -0,0 +1,43 @@
+name: Type Check
+on:
+ workflow_call:
+
+jobs:
+ type-check:
+ name: Type check
+ runs-on: ubuntu-latest
+ container: python:3.12
+ steps:
+ - uses: actions/checkout@v4
+
+ - run: |
+ pip install poetry
+ poetry install
+
+ - name: Run check
+ run: >
+ poetry run
+ mypy
+ hwi.py
+ hwilib/_base58.py
+ hwilib/_bech32.py
+ hwilib/_cli.py
+ hwilib/commands.py
+ hwilib/common.py
+ hwilib/descriptor.py
+ hwilib/devices/bitbox02.py
+ hwilib/devices/coldcard.py
+ hwilib/devices/digitalbitbox.py
+ hwilib/devices/jade.py
+ hwilib/devices/__init__.py
+ hwilib/devices/keepkey.py
+ hwilib/devices/ledger.py
+ hwilib/devices/trezor.py
+ hwilib/errors.py
+ hwilib/_script.py
+ hwilib/_serialize.py
+ hwilib/tx.py
+ hwilib/hwwclient.py
+ hwilib/__init__.py
+ hwilib/key.py
+ hwilib/udevinstaller.py
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c97e1b3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,27 @@
+__pycache__/
+*.pyc
+build/
+dist/
+hwi.egg-info/
+test/emulator.img
+test/work
+pip-wheel-metadata
+.mypy_cache/
+
+# Qt stuff
+hwiqt.pyproject.user
+hwilib/ui/ui_*.py
+
+*.stderr
+*.stdout
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+.vscode
diff --git a/.python-version b/.python-version
new file mode 100644
index 0000000..bd28b9c
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
+3.9
diff --git a/.readthedocs.yml b/.readthedocs.yml
new file mode 100644
index 0000000..b5ce899
--- /dev/null
+++ b/.readthedocs.yml
@@ -0,0 +1,13 @@
+version: 2
+
+build:
+ os: "ubuntu-22.04"
+ tools:
+ python: "3"
+
+python:
+ install:
+ - requirements: docs/requirements.txt
+
+sphinx:
+ configuration: docs/conf.py
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..370f8b2
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2017 Andrew Chow
+
+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.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c80374b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,102 @@
+# Bitcoin Hardware Wallet Interface
+
+[](https://cirrus-ci.com/github/bitcoin-core/HWI)
+[](https://hwi.readthedocs.io/en/latest/?badge=latest)
+
+The Bitcoin Hardware Wallet Interface is a Python library and command line tool for interacting with hardware wallets.
+It provides a standard way for software to work with hardware wallets without needing to implement device specific drivers.
+Python software can use the provided library (`hwilib`). Software in other languages can execute the `hwi` tool.
+
+Caveat emptor: Inclusion of a specific hardware wallet vendor does not imply any endorsement of quality or security.
+
+## Prerequisites
+
+Python 3 is required. The libraries and [udev rules](hwilib/udev/README.md) for each device must also be installed. Some libraries will need to be installed
+
+For Ubuntu/Debian:
+```
+sudo apt install libusb-1.0-0-dev libudev-dev python3-dev
+```
+
+For Centos:
+```
+sudo yum -y install python3-devel libusbx-devel systemd-devel
+```
+
+For macOS:
+```
+brew install libusb
+```
+
+## Install
+
+```
+git clone https://github.com/bitcoin-core/HWI.git
+cd HWI
+poetry install # or 'pip3 install .' or 'python3 setup.py install'
+```
+
+This project uses the [Poetry](https://github.com/sdispater/poetry) dependency manager. HWI and its dependencies can be installed via poetry by executing the following in the root source directory:
+
+```
+poetry install
+```
+
+Pip can also be used to automatically install HWI and its dependencies using the `setup.py` file (which is usually in sync with `pyproject.toml`):
+
+```
+pip3 install .
+```
+
+The `setup.py` file can be used to install HWI and its dependencies so long as `setuptools` is also installed:
+
+```
+pip3 install -U setuptools
+python3 setup.py install
+```
+
+## Dependencies
+
+See `pyproject.toml` for all dependencies. Dependencies under `[tool.poetry.dependencies]` are user dependencies, and `[tool.poetry.dev-dependencies]` for development based dependencies. These dependencies will be installed with any of the three above installation methods.
+
+## Usage
+
+To use, first enumerate all devices and find the one that you want to use with
+
+```
+./hwi.py enumerate
+```
+
+Once the device type and device path are known, issue commands to it like so:
+
+```
+./hwi.py -t <type> -d <path> <command> <command args>
+```
+
+All output will be in JSON form and sent to `stdout`.
+Additional information or prompts will be sent to `stderr` and will not necessarily be in JSON.
+This additional information is for debugging purposes.
+
+To see a complete list of available commands and global parameters, run
+`./hwi.py --help`. To see options specific to a particular command,
+pass the `--help` parameter after the command name; for example:
+
+```
+./hwi.py getdescriptors --help
+```
+
+## Documentation
+
+Documentation for HWI can be found on [readthedocs.io](https://hwi.readthedocs.io/).
+
+### Device Support
+
+For documentation on devices supported and how they are supported, please check the [device support page](https://hwi.readthedocs.io/en/latest/devices/index.html#support-matrix)
+
+### Using with Bitcoin Core
+
+See [Using Bitcoin Core with Hardware Wallets](https://hwi.readthedocs.io/en/latest/examples/bitcoin-core-usage.html).
+
+## License
+
+This project is available under the MIT License, Copyright Andrew Chow.
diff --git a/ci/build_bitbox02.sh b/ci/build_bitbox02.sh
new file mode 100755
index 0000000..29b8a0b
--- /dev/null
+++ b/ci/build_bitbox02.sh
@@ -0,0 +1,9 @@
+docker volume rm bitbox02_volume || true
+docker volume create bitbox02_volume
+CONTAINER_VERSION=$(curl https://raw.githubusercontent.com/BitBoxSwiss/bitbox02-firmware/master/.containerversion)
+docker pull shiftcrypto/firmware_v2:$CONTAINER_VERSION
+docker run -i --rm -v bitbox02_volume:/bitbox02-firmware shiftcrypto/firmware_v2:$CONTAINER_VERSION bash -c \
+ "cd /bitbox02-firmware && \
+ git clone --recursive https://github.com/BitBoxSwiss/bitbox02-firmware.git . && \
+ git config --global --add safe.directory ./ && \
+ make -j simulator"
\ No newline at end of file
diff --git a/ci/cirrus.Dockerfile b/ci/cirrus.Dockerfile
new file mode 100644
index 0000000..07c201c
--- /dev/null
+++ b/ci/cirrus.Dockerfile
@@ -0,0 +1,99 @@
+# Cache break (modify this line to break cirrus' dockerfile build cache) 1
+
+FROM python:3.9
+
+ENV DEBIAN_FRONTEND=noninteractive
+RUN apt-get update
+RUN apt-get install -y \
+ autotools-dev \
+ automake \
+ bsdmainutils \
+ build-essential \
+ ccache \
+ clang \
+ cmake \
+ curl \
+ cython3 \
+ gcc-arm-none-eabi \
+ gcc-arm-linux-gnueabihf \
+ git \
+ libboost-system-dev \
+ libboost-filesystem-dev \
+ libboost-chrono-dev \
+ libboost-test-dev \
+ libboost-thread-dev \
+ libc6-dev-armhf-cross \
+ libdb-dev \
+ libdb++-dev \
+ libevent-dev \
+ libgcrypt20-dev \
+ libnewlib-arm-none-eabi \
+ libpcsclite-dev \
+ libsdl2-dev \
+ libsdl2-image-dev \
+ libssl-dev \
+ libslirp-dev \
+ libtool \
+ libudev-dev \
+ libusb-1.0-0-dev \
+ ninja-build \
+ pkg-config \
+ qemu-user-static \
+ swig
+
+RUN pip install poetry flake8
+RUN wget https://static.rust-lang.org/rustup/dist/x86_64-unknown-linux-gnu/rustup-init
+RUN chmod +x rustup-init && ./rustup-init -y
+ENV PATH="/root/.cargo/bin:$PATH"
+RUN wget https://github.com/protocolbuffers/protobuf/releases/download/v22.0/protoc-22.0-linux-x86_64.zip
+RUN unzip protoc-22.0-linux-x86_64.zip -d /usr/local
+RUN protoc --version
+
+####################
+# Local build/test steps
+# -----------------
+# To install all simulators/tests locally, uncomment the block below,
+# then build the docker image and interactively run the tests
+# as needed.
+# e.g.,
+# docker build -f ci/cirrus.Dockerfile -t hwi_test .
+# docker run -it --entrypoint /bin/bash hwi_test
+# cd test; poetry run ./run_tests.py --ledger --coldcard --interface=cli --device-only
+# For BitBox02:
+# docker build -f ci/cirrus.Dockerfile -t hwi_test .
+# ./ci/build_bitbox02.sh
+# docker run -it -v bitbox02_volume:/test/work/bitbox02-firmware --name hwi --entrypoint /bin/bash hwi_test
+# cd test; poetry run ./run_tests.py --bitbox02 --interface=cli --device-only
+####################
+
+####################
+#ENV EMAIL=email
+#COPY pyproject.toml pyproject.toml
+#RUN poetry run pip install construct pyelftools mnemonic jsonschema
+#
+## Set up environments first to take advantage of layer caching
+#RUN mkdir test
+#COPY test/setup_environment.sh test/setup_environment.sh
+#COPY test/data/coldcard-multisig.patch test/data/coldcard-multisig.patch
+## One by one to allow for intermediate caching of successful builds
+#RUN cd test; ./setup_environment.sh --trezor-1
+#RUN cd test; ./setup_environment.sh --trezor-t
+#RUN cd test; ./setup_environment.sh --coldcard
+#RUN cd test; ./setup_environment.sh --bitbox01
+#RUN cd test; ./setup_environment.sh --ledger
+#RUN cd test; ./setup_environment.sh --keepkey
+#RUN cd test; ./setup_environment.sh --jade
+#RUN cd test; ./setup_environment.sh --bitcoind
+#
+## Once everything has been built, put rest of files in place
+## which have higher turn-over.
+#COPY test/ test/
+#COPY hwi.py hwi-qt.py README.md /
+#COPY hwilib/ /hwilib/
+#RUN poetry install
+#
+####################
+
+ENV LC_ALL=C.UTF-8
+ENV LANG=C.UTF-8
+ENV LANGUAGE=C.UTF-8
diff --git a/ci/py310.Dockerfile b/ci/py310.Dockerfile
new file mode 100644
index 0000000..299b443
--- /dev/null
+++ b/ci/py310.Dockerfile
@@ -0,0 +1,23 @@
+# Cache break (modify this line to break cirrus' dockerfile build cache) 1
+
+FROM python:3.10
+
+ENV DEBIAN_FRONTEND=noninteractive
+RUN apt-get update
+RUN apt-get install -y \
+ cython3 \
+ git \
+ libpcsclite-dev \
+ libsdl2-dev \
+ libsdl2-image-dev \
+ libslirp-dev \
+ libudev-dev \
+ libusb-1.0-0-dev \
+ qemu-user-static \
+ swig
+
+RUN pip install poetry flake8
+
+ENV LC_ALL=C.UTF-8
+ENV LANG=C.UTF-8
+ENV LANGUAGE=C.UTF-8
diff --git a/ci/py311.Dockerfile b/ci/py311.Dockerfile
new file mode 100644
index 0000000..ab80e88
--- /dev/null
+++ b/ci/py311.Dockerfile
@@ -0,0 +1,23 @@
+# Cache break (modify this line to break cirrus' dockerfile build cache)
+
+FROM python:3.11
+
+ENV DEBIAN_FRONTEND=noninteractive
+RUN apt-get update
+RUN apt-get install -y \
+ cython3 \
+ git \
+ libpcsclite-dev \
+ libsdl2-dev \
+ libsdl2-image-dev \
+ libslirp-dev \
+ libudev-dev \
+ libusb-1.0-0-dev \
+ qemu-user-static \
+ swig
+
+RUN pip install poetry flake8
+
+ENV LC_ALL=C.UTF-8
+ENV LANG=C.UTF-8
+ENV LANGUAGE=C.UTF-8
diff --git a/ci/py37.Dockerfile b/ci/py37.Dockerfile
new file mode 100644
index 0000000..fc8b677
--- /dev/null
+++ b/ci/py37.Dockerfile
@@ -0,0 +1,23 @@
+# Cache break (modify this line to break cirrus' dockerfile build cache) 1
+
+FROM python:3.7
+
+ENV DEBIAN_FRONTEND=noninteractive
+RUN apt-get update
+RUN apt-get install -y \
+ cython3 \
+ git \
+ libpcsclite-dev \
+ libsdl2-dev \
+ libsdl2-image-dev \
+ libslirp-dev \
+ libudev-dev \
+ libusb-1.0-0-dev \
+ qemu-user-static \
+ swig
+
+RUN pip install poetry flake8
+
+ENV LC_ALL=C.UTF-8
+ENV LANG=C.UTF-8
+ENV LANGUAGE=C.UTF-8
diff --git a/ci/py39.Dockerfile b/ci/py39.Dockerfile
new file mode 100644
index 0000000..9c6f5ee
--- /dev/null
+++ b/ci/py39.Dockerfile
@@ -0,0 +1,23 @@
+# Cache break (modify this line to break cirrus' dockerfile build cache) 1
+
+FROM python:3.9
+
+ENV DEBIAN_FRONTEND=noninteractive
+RUN apt-get update
+RUN apt-get install -y \
+ cython3 \
+ git \
+ libpcsclite-dev \
+ libsdl2-dev \
+ libsdl2-image-dev \
+ libslirp-dev \
+ libudev-dev \
+ libusb-1.0-0-dev \
+ qemu-user-static \
+ swig
+
+RUN pip install poetry flake8
+
+ENV LC_ALL=C.UTF-8
+ENV LANG=C.UTF-8
+ENV LANGUAGE=C.UTF-8
diff --git a/contrib/README.md b/contrib/README.md
new file mode 100644
index 0000000..b92d5fd
--- /dev/null
+++ b/contrib/README.md
@@ -0,0 +1,35 @@
+# Assorted tools
+
+## `build_bin.sh`
+
+Creates a virtualenv with the locked dependencies using Poetry. Then uses pyinstaller to create a standalone binary for the OS type currently running.
+
+## `build_dist.sh`
+
+Creates a virtualenv with the locked dependencies using Poetry. Then uses Poetry to produce deterministic builds of the wheel and sdist for upload to PyPi
+
+`faketime` needs to be installed
+
+## `build_wine.sh`
+
+Sets up Wine with Python and everything needed to build Windows binaries. Creates a virtualenv with the locked dependencies using Poetry. Then uses pyinstaller to create a standalone Windows binary.
+
+`wine` needs to be installed
+
+## `generate_setup.sh`
+
+Builds the source distribution and extracts the setup.py from it.
+
+## `build.Dockerfile`
+
+A Dockerfile for setting up the deterministic build environment.
+
+# Other files
+
+## `reproducible-python.diff`
+
+A path for python in order to do a deterministic build of Python for the deterministically built binaries.
+
+## `pyinstaller-hooks/hook-hwilib.devices.py`
+
+Pyinstaller hook so that the device drivers are actually included. Due to how the imports work, we need this hook.
diff --git a/contrib/autopep8.sh b/contrib/autopep8.sh
new file mode 100755
index 0000000..a81fd5d
--- /dev/null
+++ b/contrib/autopep8.sh
@@ -0,0 +1,4 @@
+#! /bin/bash
+# Script for running autopep8
+
+autopep8 --in-place test/*.py hwilib/*.py hwilib/devices/*.py hwi.py
diff --git a/contrib/build-wine.Dockerfile b/contrib/build-wine.Dockerfile
new file mode 100644
index 0000000..335b580
--- /dev/null
+++ b/contrib/build-wine.Dockerfile
@@ -0,0 +1,30 @@
+FROM debian:bookworm-slim
+
+SHELL ["/bin/bash", "-c"]
+
+ENV DEBIAN_FRONTEND=noninteractive
+RUN apt-get update
+RUN apt-get install -y \
+ build-essential \
+ g++-mingw-w64-x86-64 \
+ faketime \
+ dos2unix \
+ zip \
+ wget
+
+RUN dpkg --add-architecture i386
+RUN wget -nc https://dl.winehq.org/wine-builds/winehq.key
+RUN apt-key add winehq.key
+RUN echo "deb https://dl.winehq.org/wine-builds/debian/ bookworm main" >> /etc/apt/sources.list
+RUN apt-get update
+RUN apt-get install --install-recommends -y \
+ wine-stable-amd64 \
+ wine-stable-i386 \
+ wine-stable \
+ winehq-stable \
+ p7zip-full
+
+ENV LC_ALL=C.UTF-8
+ENV LANG=C.UTF-8
+ENV LANGUAGE=C.UTF-8
+
diff --git a/contrib/build.Dockerfile b/contrib/build.Dockerfile
new file mode 100644
index 0000000..4e10253
--- /dev/null
+++ b/contrib/build.Dockerfile
@@ -0,0 +1,45 @@
+FROM debian:bookworm-slim
+
+SHELL ["/bin/bash", "-c"]
+
+ENV DEBIAN_FRONTEND=noninteractive
+RUN apt-get update
+RUN apt-get install -y \
+ apt-transport-https \
+ git \
+ make \
+ build-essential \
+ libssl-dev \
+ zlib1g-dev \
+ libbz2-dev \
+ libreadline-dev \
+ libsqlite3-dev \
+ wget \
+ curl \
+ llvm \
+ libncurses5-dev \
+ xz-utils \
+ libxml2-dev \
+ libxmlsec1-dev \
+ libffi-dev \
+ liblzma-dev \
+ libusb-1.0-0-dev \
+ libudev-dev \
+ faketime \
+ zip \
+ pyqt5-dev \
+ libglib2.0-0
+
+RUN curl https://pyenv.run | bash
+ENV PYENV_ROOT="/root/.pyenv"
+ENV PATH="$PYENV_ROOT/bin:$PATH"
+
+COPY contrib/reproducible-python.diff /opt/reproducible-python.diff
+ENV PYTHON_CONFIGURE_OPTS="--enable-shared"
+ENV BUILD_DATE="Jan 1 2019"
+ENV BUILD_TIME="00:00:00"
+RUN eval "$(pyenv init --path)" && eval "$(pyenv virtualenv-init -)" && cat /opt/reproducible-python.diff | pyenv install -kp 3.9.19
+
+ENV LC_ALL=C.UTF-8
+ENV LANG=C.UTF-8
+ENV LANGUAGE=C.UTF-8
diff --git a/contrib/build_bin.sh b/contrib/build_bin.sh
new file mode 100755
index 0000000..2db94f4
--- /dev/null
+++ b/contrib/build_bin.sh
@@ -0,0 +1,63 @@
+#! /bin/bash
+# Script for building standalone binary releases deterministically
+# Usage: First script parameter can be `--without-gui` to build without UI support
+
+set -ex
+
+ARCH=$(uname -m | tr '[:upper:]' '[:lower:]')
+
+eval "$(pyenv init --path)"
+eval "$(pyenv virtualenv-init -)"
+pip install -U pip
+pip install poetry
+
+gui_support="${1:---with-gui}";
+
+# Setup poetry and install the dependencies
+if [[ $gui_support == "--with-gui" && $ARCH == "x86_64" ]]; then
+ poetry install -E qt
+else
+ poetry install
+fi
+
+# We also need to change the timestamps of all of the base library files
+lib_dir=$(pyenv prefix)/lib/python3.9
+TZ=UTC find ${lib_dir} -name '*.py' -type f -execdir touch -t "201901010000.00" '{}' \;
+
+# Make the standalone binary
+export PYTHONHASHSEED=42
+poetry run pyinstaller hwi.spec
+
+if [[ $gui_support == "--with-gui" && $ARCH == "x86_64" ]]; then
+ poetry run contrib/generate-ui.sh
+ poetry run pyinstaller hwi-qt.spec
+fi
+
+unset PYTHONHASHSEED
+
+# Make the final compressed package
+pushd dist
+VERSION=`poetry run hwi --version | cut -d " " -f 2`
+OS=`uname | tr '[:upper:]' '[:lower:]'`
+if [[ $OS == "darwin" ]]; then
+ OS="mac"
+fi
+
+target_tarfile="hwi-${VERSION}-${OS}-${ARCH}.tar.gz"
+
+if [[ $gui_support == "--with-gui" ]]; then
+ tar -czf $target_tarfile hwi hwi-qt
+else
+ tar -czf $target_tarfile hwi
+fi
+
+# Copy the binaries to subdir for shasum
+target_dir="$target_tarfile.dir"
+mkdir $target_dir
+mv hwi $target_dir
+
+if [[ $gui_support == "--with-gui" && $ARCH == "x86_64" ]]; then
+ mv hwi-qt $target_dir
+fi
+
+popd
diff --git a/contrib/build_dist.sh b/contrib/build_dist.sh
new file mode 100755
index 0000000..e15b9e5
--- /dev/null
+++ b/contrib/build_dist.sh
@@ -0,0 +1,24 @@
+#! /bin/bash
+# Script for building pypi distribution archives deterministically
+# Usage: First script parameter can be `--without-gui` to build without UI support
+
+set -ex
+
+eval "$(pyenv init --path)"
+eval "$(pyenv virtualenv-init -)"
+pip install -U pip
+pip install poetry
+
+gui_support="${1:---with-gui}";
+
+# Setup poetry and install the dependencies
+if [[ $gui_support == "--with-gui" ]]; then
+ poetry install -E qt
+else
+ poetry install
+fi
+
+# Make the distribution archives for pypi
+poetry build -f wheel
+# faketime is needed to make sdist detereministic
+TZ=UTC faketime -f "2019-01-01 00:00:00" poetry build -f sdist
diff --git a/contrib/build_wine.sh b/contrib/build_wine.sh
new file mode 100755
index 0000000..64d211f
--- /dev/null
+++ b/contrib/build_wine.sh
@@ -0,0 +1,100 @@
+#!/bin/bash
+# Script which sets up Wine and builds the Windows standalone binary
+
+set -ex
+
+PYTHON_VERSION=3.9.13
+
+PYTHON_FOLDER="python3"
+PYHOME="c:/$PYTHON_FOLDER"
+PYTHON="wine $PYHOME/python.exe -OO -B"
+
+LIBUSB_VERSION=1.0.26
+LIBUSB_URL=https://github.com/libusb/libusb/releases/download/v${LIBUSB_VERSION}/libusb-${LIBUSB_VERSION}.tar.bz2
+LIBUSB_HASH="12ce7a61fc9854d1d2a1ffe095f7b5fac19ddba095c259e6067a46500381b5a5"
+
+WINDOWS_SDK_URL=http://go.microsoft.com/fwlink/p/?LinkID=2033686
+WINDOWS_SDK_HASH="016981259708e1afcab666c7c1ff44d1c4d63b5e778af8bc41b4f6db3d27961a"
+WINDOWS_SDK_VERSION=10.0.17763.0
+
+wine 'wineboot'
+
+# Install Python
+# Get the PGP keys
+wget -O pubkeys.txt -N -c "https://keybase.io/stevedower/pgp_keys.asc?fingerprint=7ed10b6531d7c8e1bc296021fc624643487034e5"
+gpg --import pubkeys.txt
+rm pubkeys.txt
+
+# Install python components
+for msifile in core dev exe lib pip tools; do
+ wget -N -c "https://www.python.org/ftp/python/$PYTHON_VERSION/amd64/${msifile}.msi"
+ wget -N -c "https://www.python.org/ftp/python/$PYTHON_VERSION/amd64/${msifile}.msi.asc"
+ gpg --verify "${msifile}.msi.asc" "${msifile}.msi"
+ wine msiexec /i "${msifile}.msi" /qb TARGETDIR=$PYHOME
+ rm $msifile.msi*
+done
+
+# Get and build libusb
+wget -N -c -O libusb.tar.bz2 "$LIBUSB_URL"
+echo "$LIBUSB_HASH libusb.tar.bz2" | sha256sum -c
+tar -xf libusb.tar.bz2
+pushd "libusb-$LIBUSB_VERSION"
+./configure --host=x86_64-w64-mingw32
+faketime -f "2019-01-01 00:00:00" make
+cp libusb/.libs/libusb-1.0.dll ~/.wine/drive_c/python3/
+popd
+rm -r libusb*
+
+# Get the Windows SDK
+pushd `mktemp -d`
+wget -O sdk.iso "$WINDOWS_SDK_URL"
+echo "$WINDOWS_SDK_HASH sdk.iso" | sha256sum -c
+7z e sdk.iso
+wine msiexec /i "Universal CRT Redistributable-x86_en-us.msi"
+cp ~/.wine/drive_c/Program\ Files\ \(x86\)/Windows\ Kits/10/Redist/${WINDOWS_SDK_VERSION}/ucrt/DLLs/x64/*.dll ~/.wine/drive_c/windows/system32/
+popd
+
+# Update pip
+$PYTHON -m pip install -U pip
+
+# Install Poetry and things needed for pyinstaller
+$PYTHON -m pip install poetry
+
+# We also need to change the timestamps of all of the base library files
+lib_dir=~/.wine/drive_c/python3/Lib
+TZ=UTC find ${lib_dir} -name '*.py' -type f -execdir touch -t "201901010000.00" '{}' \;
+
+# Install python dependencies
+POETRY="wine $PYHOME/Scripts/poetry.exe"
+sleep 5 # For some reason, pausing for a few seconds makes the next step work
+$POETRY install -E qt
+
+# make the ui files
+pushd hwilib/ui
+for file in *.ui
+do
+ gen_file=ui_`echo $file| cut -d. -f1`.py
+ $POETRY run pyside2-uic $file -o $gen_file
+ sed -i 's/raise()/raise_()/g' $gen_file
+done
+popd
+
+# Do the build
+export PYTHONHASHSEED=42
+$POETRY run pyinstaller hwi.spec
+$POETRY run pyinstaller hwi-qt.spec
+unset PYTHONHASHSEED
+
+# Make the final compressed package
+pushd dist
+VERSION=`$POETRY run hwi --version | cut -d " " -f 2 | dos2unix`
+target_zipfile="hwi-${VERSION}-windows-x86_64.zip"
+zip $target_zipfile hwi.exe hwi-qt.exe
+
+# Copy the binaries to subdir for shasum
+target_dir="$target_zipfile.dir"
+mkdir $target_dir
+mv hwi.exe $target_dir
+mv hwi-qt.exe $target_dir
+
+popd
diff --git a/contrib/generate-ui.sh b/contrib/generate-ui.sh
new file mode 100755
index 0000000..9fc982e
--- /dev/null
+++ b/contrib/generate-ui.sh
@@ -0,0 +1,12 @@
+#! /bin/bash
+
+set -ex
+
+pushd hwilib/ui
+for file in *.ui
+do
+ gen_file=ui_`echo $file| cut -d. -f1`.py
+ pyside2-uic $file -o $gen_file
+ sed -i'' -e 's/raise()/raise_()/g' $gen_file
+done
+popd
diff --git a/contrib/generate_setup.sh b/contrib/generate_setup.sh
new file mode 100755
index 0000000..3e3424d
--- /dev/null
+++ b/contrib/generate_setup.sh
@@ -0,0 +1,33 @@
+#! /bin/bash
+# Generates the setup.py file
+
+set -ex
+
+# Setup poetry and install the dependencies
+poetry install -E qt
+
+# Build the source distribution
+poetry build -f sdist
+
+# Extract setup.py from the distribution
+unset -v tarball
+for file in dist/*
+do
+ if [[ $file -nt $tarball && $file == *".tar.gz" ]]
+ then
+ tarball=$file
+ fi
+done
+unset -v toextract
+for file in `tar -tf $tarball`
+do
+ if [[ $file == *"setup.py" ]]
+ then
+ toextract=$file
+ fi
+done
+tar -xf $tarball $toextract
+mv $toextract .
+dir=`echo $toextract | cut -f1 -d"/"`
+rm -r $dir
+sed -i 's/distutils.core/setuptools/g' setup.py
diff --git a/contrib/make_shasums.sh b/contrib/make_shasums.sh
new file mode 100755
index 0000000..4a7949c
--- /dev/null
+++ b/contrib/make_shasums.sh
@@ -0,0 +1,14 @@
+#! /bin/bash
+# Script for generating the SHA256SUMS.txt file
+
+set -ex
+
+pushd dist
+
+sums=SHA256SUMS.txt
+sum_files=`find . -type f -not -name *$sums* | sort`
+sha256sum $sum_files > $sums
+sed -i 's/\.\///g' $sums
+sed -i 's/\.dir//g' $sums
+
+popd
diff --git a/contrib/pyinstaller-hooks/hook-hwilib.devices.py b/contrib/pyinstaller-hooks/hook-hwilib.devices.py
new file mode 100644
index 0000000..260d262
--- /dev/null
+++ b/contrib/pyinstaller-hooks/hook-hwilib.devices.py
@@ -0,0 +1,4 @@
+from hwilib.devices import __all__
+hiddenimports = []
+for d in __all__:
+ hiddenimports.append('hwilib.devices.' + d)
diff --git a/contrib/reproducible-python.diff b/contrib/reproducible-python.diff
new file mode 100644
index 0000000..13721e0
--- /dev/null
+++ b/contrib/reproducible-python.diff
@@ -0,0 +1,13 @@
+# DP: Build getbuildinfo.o with DATE/TIME values when defined
+
+--- Makefile.pre.in
++++ Makefile.pre.in
+@@ -741,6 +741,8 @@ Modules/getbuildinfo.o: $(PARSER_OBJS) \
+ -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 Makefile
diff --git a/docs/_templates/layout.html b/docs/_templates/layout.html
new file mode 100644
index 0000000..cf2b2cd
--- /dev/null
+++ b/docs/_templates/layout.html
@@ -0,0 +1,8 @@
+{% extends "!layout.html" %}
+ {% block footer %} {{ super() }}
+
+ <style>
+ .wy-nav-content { max-width: none; }
+ </style>
+
+{% endblock %}
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 0000000..5956eda
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,75 @@
+# Configuration file for the Sphinx documentation builder.
+#
+# This file only contains a selection of the most common options. For a full
+# list see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+
+# -- Path setup --------------------------------------------------------------
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#
+import os
+import sys
+
+sys.path.insert(0, os.path.abspath('..'))
+
+
+# -- Project information -----------------------------------------------------
+
+project = 'Hardware Wallet Interface'
+copyright = '2021, The Hardware Wallet Interface Developers'
+author = 'The Hardware Wallet Interface Developers'
+
+# The full version, including alpha/beta/rc tags
+release = '2.0.0-dev'
+
+
+# -- General configuration ---------------------------------------------------
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = [
+ "sphinx.ext.autodoc",
+ "sphinx_rtd_theme",
+ "sphinxcontrib.autoprogram",
+]
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# This pattern also affects html_static_path and html_extra_path.
+exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
+
+# Autodoc options
+autodoc_default_options = {
+ "inherited-members": True,
+}
+
+# -- Options for HTML output -------------------------------------------------
+
+# The theme to use for HTML and HTML Help pages. See the documentation for
+# a list of builtin themes.
+#
+html_theme = "sphinx_rtd_theme"
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+# html_static_path = ['_static']
+
+# Autodoc config to include type hints in the description
+autodoc_typehints = "description"
+
+# Order the autodoc members by type
+autodoc_member_order = "bysource"
+
+# Show both class and init docstring
+autoclass_content = "both"
+
+# Mock these imports
+autodoc_mock_imports = ["hid", "ecdsa", "pyaes", "mnemonic", "typing_extensions", "usb1", "PySide2"]
diff --git a/docs/development/index.rst b/docs/development/index.rst
new file mode 100644
index 0000000..43c58b5
--- /dev/null
+++ b/docs/development/index.rst
@@ -0,0 +1,8 @@
+Development
+***********
+
+.. toctree::
+ :caption: Contents:
+
+ release-process
+ internal-api
diff --git a/docs/development/internal-api.rst b/docs/development/internal-api.rst
new file mode 100644
index 0000000..3de8092
--- /dev/null
+++ b/docs/development/internal-api.rst
@@ -0,0 +1,13 @@
+Internal API Documentation
+==========================
+
+In addition to the public API, the classes and functions documented here are available for use within HWI itself.
+
+.. automodule:: hwilib._base58
+ :members:
+.. automodule:: hwilib._bech32
+ :members:
+.. automodule:: hwilib._script
+ :members:
+.. automodule:: hwilib._serialize
+ :members:
diff --git a/docs/development/release-process.rst b/docs/development/release-process.rst
new file mode 100644
index 0000000..fb739ff
--- /dev/null
+++ b/docs/development/release-process.rst
@@ -0,0 +1,53 @@
+Release Process
+***************
+
+1. Bump version number in ``pyproject.toml`` and ``hwilib/__init__.py``, generate the setup.py file, and git tag release
+2. Build distribution archives for PyPi with ``contrib/build_dist.sh``
+3. For MacOS and Linux, use ``contrib/build_bin.sh``. This needs to be run on a macOS machine for the macOS binary and on a Linux machine for the linux one.
+4. For Windows, use ``contrib/build_wine.sh`` to build the Windows binary using wine
+5. Make ``SHA256SUMS.txt`` using ``contrib/make_shasums.sh``.
+6. Make ``SHA256SUMS.txt.asc`` using ``gpg --clearsign SHA256SUMS.txt``
+7. Upload distribution archives to PyPi
+8. Upload distribution archives and standalone binaries to Github
+
+Deterministic builds with Docker
+================================
+
+Create the docker images::
+
+ docker build --no-cache -t hwi-builder -f contrib/build.Dockerfile .
+ docker build --no-cache -t hwi-wine-builder -f contrib/build-wine.Dockerfile .
+
+ # arm64
+ sudo apt-get install qemu-user-static
+ docker buildx build --no-cache --platform linux/arm64 -t hwi-builder-arm64 -f contrib/build.Dockerfile .
+
+Build everything::
+
+ docker run -it --name hwi-builder -v $PWD:/opt/hwi --rm --workdir /opt/hwi hwi-builder /bin/bash -c "contrib/build_bin.sh && contrib/build_dist.sh"
+ docker run -it --name hwi-wine-builder -v $PWD:/opt/hwi --rm --workdir /opt/hwi hwi-wine-builder /bin/bash -c "contrib/build_wine.sh"
+ docker run --platform linux/arm64 -it --rm --name hwi-builder-arm64 -v $PWD:/opt/hwi --workdir /opt/hwi hwi-builder-arm64 /bin/bash -c "contrib/build_bin.sh --without-gui && contrib/build_dist.sh --without-gui"
+
+Building macOS binary
+=====================
+
+Note that the macOS build is non-deterministic.
+
+First install `pyenv <https://github.com/pyenv/pyenv>`_ using whichever method you prefer.
+
+Then a deterministic build of Python 3.9.19 needs to be installed. This can be done with the patch in ``contrib/reproducible-python.diff``. First ``cd`` into HWI's source tree. Then use::
+
+ cat contrib/reproducible-python.diff | PYTHON_CONFIGURE_OPTS="--enable-framework" BUILD_DATE="Jan 1 2019" BUILD_TIME="00:00:00" pyenv install -kp 3.9.19
+
+Make sure that python 3.9.19 is active::
+
+ $ python --version
+ Python 3.9.19
+
+Now install `Poetry <https://github.com/sdispater/poetry>`_ with ``pip install poetry``
+
+Additional dependencies can be installed with::
+
+ brew install libusb
+
+Build the binaries by using ``contrib/build_bin.sh``.
diff --git a/docs/devices/index.rst b/docs/devices/index.rst
new file mode 100644
index 0000000..533a6b4
--- /dev/null
+++ b/docs/devices/index.rst
@@ -0,0 +1,110 @@
+Supported Devices
+*****************
+
+Support Matrix
+==============
+
+The table below lists what devices and features are supported for each device.
+
+* ``✓`` - supported by the firmware and implemented in HWI
+* ``✗`` - supported by the firmware and not implemented in HWI
+* ``―`` - not supported by the firmware
+
++------------------------------------+---------------+---------------+--------------+--------------------------------+----------+----------+---------+----------+------------------+
+| Feature \\ Device | Ledger Nano X | Ledger Nano S | Trezor One | Trezor Model T, Safe 3, Safe 5 | BitBox01 | BitBox02 | KeepKey | Coldcard | Blockstream Jade |
++====================================+===============+===============+==============+================================+==========+==========+=========+==========+==================+
+| Support Planned | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
++------------------------------------+---------------+---------------+--------------+--------------------------------+----------+----------+---------+----------+------------------+
+| Implemented | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
++------------------------------------+---------------+---------------+--------------+--------------------------------+----------+----------+---------+----------+------------------+
+| xpub retrieval | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
++------------------------------------+---------------+---------------+--------------+--------------------------------+----------+----------+---------+----------+------------------+
+| Message Signing | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
++------------------------------------+---------------+---------------+--------------+--------------------------------+----------+----------+---------+----------+------------------+
+| Device Setup | ― | ― | ✓ | ✓ | ✓ | ✓ | ✓ | ― | ― |
++------------------------------------+---------------+---------------+--------------+--------------------------------+----------+----------+---------+----------+------------------+
+| Device Wipe | ― | ― | ✓ | ✓ | ✓ | ✓ | ✓ | ― | ― |
++------------------------------------+---------------+---------------+--------------+--------------------------------+----------+----------+---------+----------+------------------+
+| Device Recovery | ― | ― | ✓ | ✓ | ― | ✓ | ✓ | ― | ― |
++------------------------------------+---------------+---------------+--------------+--------------------------------+----------+----------+---------+----------+------------------+
+| Device Backup | ― | ― | ― | ― | ✓ | ✓ | ― | ✓ | ― |
++------------------------------------+---------------+---------------+--------------+--------------------------------+----------+----------+---------+----------+------------------+
+| P2PKH Inputs | ✓ | ✓ | ✓ | ✓ | ✓ | ― | ✓ | ✓ | ✓ |
++------------------------------------+---------------+---------------+--------------+--------------------------------+----------+----------+---------+----------+------------------+
+| P2SH-P2WPKH Inputs | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
++------------------------------------+---------------+---------------+--------------+--------------------------------+----------+----------+---------+----------+------------------+
+| P2WPKH Inputs | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
++------------------------------------+---------------+---------------+--------------+--------------------------------+----------+----------+---------+----------+------------------+
+| P2SH Multisig Inputs | ✓ | ✓ | ✓ | ✓ | ✓ | ― | ✓ | ✓ | ✓ |
++------------------------------------+---------------+---------------+--------------+--------------------------------+----------+----------+---------+----------+------------------+
+| P2SH-P2WSH Multisig Inputs | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
++------------------------------------+---------------+---------------+--------------+--------------------------------+----------+----------+---------+----------+------------------+
+| P2WSH Multisig Inputs | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
++------------------------------------+---------------+---------------+--------------+--------------------------------+----------+----------+---------+----------+------------------+
+| Bare Multisig Inputs | ✓ | ✓ | ― | ― | ✓ | ― | ― | ― | ✓ |
++------------------------------------+---------------+---------------+--------------+--------------------------------+----------+----------+---------+----------+------------------+
+| Taproot Inputs | ✓* | ✓* | ✓* | ✓* | ― | ― | ― | ― | ― |
++------------------------------------+---------------+---------------+--------------+--------------------------------+----------+----------+---------+----------+------------------+
+| Arbitrary scriptPubKey Inputs | ✓ | ✓ | ― | ― | ✓ | ― | ― | ― | ✓ |
++------------------------------------+---------------+---------------+--------------+--------------------------------+----------+----------+---------+----------+------------------+
+| Arbitrary redeemScript Inputs | ✓ | ✓ | ― | ― | ✓ | ― | ― | ― | ✓ |
++------------------------------------+---------------+---------------+--------------+--------------------------------+----------+----------+---------+----------+------------------+
+| Arbitrary witnessScript Inputs | ✓ | ✓ | ― | ― | ✓ | ― | ― | ― | ✓ |
++------------------------------------+---------------+---------------+--------------+--------------------------------+----------+----------+---------+----------+------------------+
+| Non-wallet inputs | ✓ | ✓ | ✗\ :sup:`1` | ✗\ :sup:`2` | ✓ | ― | ✓ | ✓ | ✓ |
++------------------------------------+---------------+---------------+--------------+--------------------------------+----------+----------+---------+----------+------------------+
+| Mixed Segwit and Non-Segwit Inputs | ― | ― | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
++------------------------------------+---------------+---------------+--------------+--------------------------------+----------+----------+---------+----------+------------------+
+| Display on device screen | ✓ | ✓ | ✓ | ✓ | ― | ✓ | ✓ | ✓ | ✓ |
++------------------------------------+---------------+---------------+--------------+--------------------------------+----------+----------+---------+----------+------------------+
+
+* 1 - Support removed for devices with firmware 1.10.6 and greater.
+* 2 - Support removed for devices with firmware 2.4.4 and greater.
+
+\* There are some caveats. See the `sign_tx` for these devices.
+
+Support Policy
+================
+
+For a device to be supported by HWI, it must:
+
+* Use open source firmware as much as possible
+
+ * Entirely closed source devices will be rejected
+ * Devices may have closed source firmware components if required to under a NDA (e.g. a secure element with NDA)
+
+* Publicly documented communication protocol
+
+ * It is preferred to both document the protocol and provide a Python library for using it
+ * The library, with its own documentation, can suffice as "publicly documented"
+
+* Either (but preferably both):
+
+ * A simulator/emulator is available for automated tests to be run
+ * A promise to maintain and support from the vendor:
+
+Device support may be dropped:
+
+* If promised vendor maintenance and support disappears
+
+ * If there are continuous issues with the device and the vendor has failed to provide support and updates
+
+* If the device no longer receives security updates and there are known vulnerabilities and issues
+
+Device APIs
+===========
+
+.. automodule:: hwilib.devices.ledger
+ :members:
+.. automodule:: hwilib.devices.trezor
+ :members:
+.. automodule:: hwilib.devices.digitalbitbox
+ :members:
+.. automodule:: hwilib.devices.bitbox02
+ :members:
+.. automodule:: hwilib.devices.keepkey
+ :members:
+.. automodule:: hwilib.devices.coldcard
+ :members:
+.. automodule:: hwilib.devices.jade
+ :members:
diff --git a/docs/examples/bitcoin-core-usage.rst b/docs/examples/bitcoin-core-usage.rst
new file mode 100644
index 0000000..cf0170a
--- /dev/null
+++ b/docs/examples/bitcoin-core-usage.rst
@@ -0,0 +1,285 @@
+Using Bitcoin Core with Hardware Wallets
+****************************************
+
+This approach is fairly manual, requires the command line, and Bitcoin Core >=0.21.0.
+
+Note: For this guide, code lines prefixed with ``$`` means that the command is typed in the terminal. Lines without ``$`` are output of the commands.
+
+Disclaimer
+==========
+
+We are not liable for any coins that may be lost through this method. The software mentioned may have bugs. Use at your own risk.
+
+Software
+--------
+
+Bitcoin Core
+^^^^^^^^^^^^
+
+This method of using hardware wallets uses Bitcoin Core as the wallet for monitoring the blockchain. It allows a user to use their own full node instead of relying on an SPV wallet or vendor provided software.
+
+HWI works with Bitcoin Core >=0.18.0.
+However this guide will require Bitcoin Core >=0.21.0 as it uses Descriptor Wallets.
+
+Setup
+=====
+
+Clone Bitcoin Core and build it. Clone HWI.
+
+::
+
+ $ git clone https://github.com/bitcoin/bitcoin.git
+ $ cd bitcoin
+ $ cmake -B build
+ $ cmake --build build
+ $ build/bin/bitcoind -daemon -addresstype=bech32 -changetype=bech32
+ $ cd ..
+ $ git clone https://github.com/bitcoin-core/HWI.git
+ $ cd HWI
+ $ python3 setup.py install
+
+You may need some dependencies, on ubuntu install ``libudev-dev`` and ``libusb-1.0-0-dev``
+
+Now we need to find our hardware wallet. We do this using::
+
+ $ ./hwi.py enumerate
+ [{"type": "coldcard", "model": "coldcard", "path": "0003:0005:00", "needs_pin_sent": false, "needs_passphrase_sent": false, "fingerprint": "e5dbc9cb"}]
+
+For this example, we will use the Coldcard. As we can see, the device path is ``0003:0005:00``. The fingerprint of the master key is ``e5dbc9cb``. Now that we have the device, we can issue commands to it. So now we want to get some keys and import them into Core.
+We will be fetching keys at the BIP 84 default. If ``--path`` and ``--internal`` are not
+specified, both receiving and change address descriptors are generated.
+
+::
+
+ $ ./hwi.py -f e5dbc9cb getkeypool 0 1000
+ [{"desc": "wpkh([e5dbc9cb/84'/0'/0']xpub6CbtS57jivMSuzcvp5YZxp6JhUU8YWup2axi2xkQRVHY8w4otp8YkEvfWBHgE5rA2AJYNHquuRoLFFdWeSi1UgVohcUeM7SkE9c8NftRwRJ/0/*)#cwyap6p3", "range": [0, 1000], "timestamp": "now", "internal": false, "keypool": true, "active": true, "watchonly": true}, {"desc": "wpkh([e5dbc9cb/84'/0'/0']xpub6CbtS57jivMSuzcvp5YZxp6JhUU8YWup2axi2xkQRVHY8w4otp8YkEvfWBHgE5rA2AJYNHquuRoLFFdWeSi1UgVohcUeM7SkE9c8NftRwRJ/1/*)#f6puu03f", "range": [0, 1000], "timestamp": "now", "internal": true, "keypool": true, "active": true, "watchonly": true}]
+
+We now create a new Bitcoin Core Descriptor Wallet and import the keys into Bitcoin Core. The output is formatted properly for Bitcoin Core so it can be copy and pasted.
+
+::
+
+ $ ../bitcoin/src/bitcoin-cli -named createwallet wallet_name=hwicoldcard disable_private_keys=true descriptors=true
+ {
+ "name": "hwicoldcard",
+ "warning": "Wallet is an experimental descriptor wallet"
+ }
+ $ ../bitcoin/src/bitcoin-cli -rpcwallet=hwicoldcard importdescriptors '[{"desc": "wpkh([e5dbc9cb/84\'/0\'/0\']xpub6CbtS57jivMSuzcvp5YZxp6JhUU8YWup2axi2xkQRVHY8w4otp8YkEvfWBHgE5rA2AJYNHquuRoLFFdWeSi1UgVohcUeM7SkE9c8NftRwRJ/0/*)#cwyap6p3", "range": [0, 1000], "timestamp": "now", "internal": false, "keypool": true, "active": true, "watchonly": true}, {"desc": "wpkh([e5dbc9cb/84\'/0\'/0\']xpub6CbtS57jivMSuzcvp5YZxp6JhUU8YWup2axi2xkQRVHY8w4otp8YkEvfWBHgE5rA2AJYNHquuRoLFFdWeSi1UgVohcUeM7SkE9c8NftRwRJ/1/*)#f6puu03f", "range": [0, 1000], "timestamp": "now", "internal": true, "keypool": true, "active": true, "watchonly": true}]'
+ [
+ {
+ "success": true
+ },
+ {
+ "success": true
+ }
+ ]
+
+The Bitcoin Core wallet is now setup to watch two thousand keys (1000 normal, 1000 change) from your hardware wallet and you can use it to track your balances and create transactions. The transactions will need to be signed through HWI.
+
+If the wallet was previously used, you will need to rescan the blockchain. You can either do this using the ``rescanblockchain`` command or editing the ``timestamp`` in the ``importdescriptors`` command.
+Here are some examples (``<blockheight>`` refers to a block height before the wallet was created).
+
+::
+
+ $ ../bitcoin/src/bitcoin-cli rescanblockchain <blockheight>
+ $ ../bitcoin/src/bitcoin-cli rescanblockchain 500000 # Rescan from block 500000
+
+ $ ../bitcoin/src/bitcoin-cli -rpcwallet=hwicoldcard importdescriptors '[{"desc": "wpkh([e5dbc9cb/84\'/0\'/0\']xpub6CbtS57jivMSuzcvp5YZxp6JhUU8YWup2axi2xkQRVHY8w4otp8YkEvfWBHgE5rA2AJYNHquuRoLFFdWeSi1UgVohcUeM7SkE9c8NftRwRJ/0/*)#cwyap6p3", "range": [0, 1000], "timestamp": <blockheight>, "internal": false, "keypool": true, "active": true, "watchonly": true}, {"desc": "wpkh([e5dbc9cb/84\'/0\'/0\']xpub6CbtS57jivMSuzcvp5YZxp6JhUU8YWup2axi2xkQRVHY8w4otp8YkEvfWBHgE5rA2AJYNHquuRoLFFdWeSi1UgVohcUeM7SkE9c8NftRwRJ/1/*)#f6puu03f", "range": [0, 1000], "timestamp": <blockheight>, "internal": true, "keypool": true, "active": true, "watchonly": true}]'
+ $ ../bitcoin/src/bitcoin-cli -rpcwallet=hwicoldcard importdescriptors '[{"desc": "wpkh([e5dbc9cb/84\'/0\'/0\']xpub6CbtS57jivMSuzcvp5YZxp6JhUU8YWup2axi2xkQRVHY8w4otp8YkEvfWBHgE5rA2AJYNHquuRoLFFdWeSi1UgVohcUeM7SkE9c8NftRwRJ/0/*)#cwyap6p3", "range": [0, 1000], "timestamp": 500000, "internal": false, "keypool": true, "active": true, "watchonly": true}, {"desc": "wpkh([e5dbc9cb/84\'/0\'/0\']xpub6CbtS57jivMSuzcvp5YZxp6JhUU8YWup2axi2xkQRVHY8w4otp8YkEvfWBHgE5rA2AJYNHquuRoLFFdWeSi1UgVohcUeM7SkE9c8NftRwRJ/1/*)#f6puu03f", "range": [0, 1000], "timestamp": 500000, "internal": true, "keypool": true, "active": true, "watchonly": true}]' # Imports and rescans from block 500000
+
+Usage
+=====
+
+Usage of this primarily involves Bitcoin Core. Currently the GUI only supports generating new receive addresses (once all of the keys are imported) so this guide will only cover the command line.
+
+Receiving
+---------
+
+From the folder containing ``bitcoin`` and ``HWI``, go into ``bitcoin``. We will be doing most of the commands here.
+
+::
+
+ $ cd bitcoin
+
+To get a new address, use ``getnewaddress`` as you normally would
+
+::
+
+ $ src/bitcoin-cli -rpcwallet=hwicoldcard getnewaddress
+ bc1q2xsn08w749d2tfm7qrkvztlxfmq2564sly4dwl
+
+This address belongs to your hardware wallet. You can check this by doing ``getaddressinfo``::
+
+ $ src/bitcoin-cli -rpcwallet=hwicoldcard getaddressinfo bc1q2xsn08w749d2tfm7qrkvztlxfmq2564sly4dwl
+ {
+ "address": "bc1q2xsn08w749d2tfm7qrkvztlxfmq2564sly4dwl",
+ "scriptPubKey": "001451a1379ddea95aa5a77e00ecc12fe64ec0aa6ab0",
+ "ismine": true,
+ "solvable": true,
+ "desc": "wpkh([e5dbc9cb/84'/0'/0'/0/0]0325ccb1f60a3d0640cbc3bfa1cefc34512d50c32d0e7c102b62e18f23ab69fbc5)#je3ch2kg",
+ "parent_desc": "wpkh([e5dbc9cb/84'/0'/0']xpub6CbtS57jivMSuzcvp5YZxp6JhUU8YWup2axi2xkQRVHY8w4otp8YkEvfWBHgE5rA2AJYNHquuRoLFFdWeSi1UgVohcUeM7SkE9c8NftRwRJ/0/*)#cwyap6p3",
+ "iswatchonly": false,
+ "isscript": false,
+ "iswitness": true,
+ "witness_version": 0,
+ "witness_program": "51a1379ddea95aa5a77e00ecc12fe64ec0aa6ab0",
+ "pubkey": "0325ccb1f60a3d0640cbc3bfa1cefc34512d50c32d0e7c102b62e18f23ab69fbc5",
+ "ischange": false,
+ "timestamp": 1614190663,
+ "hdkeypath": "m/84'/0'/0'/0/0",
+ "hdseedid": "0000000000000000000000000000000000000000",
+ "hdmasterfingerprint": "e5dbc9cb",
+ "labels": [
+ ""
+ ]
+ }
+
+You can give this out to people as you normally would. When coins are sent to it, you will see them in your Bitcoin Core wallet as watch-only.
+
+Sending
+=======
+
+To send Bitcoin, we will use ``walletcreatefundedpsbt``. This will create a Partially Signed Bitcoin Transaction which is funded by inputs from the wallets (i.e. you're watching only inputs selected with Bitcoin Core's coin selection algorithm).
+This PSBT can be used with HWI to produce a signed PSBT which can then be finalized and broadcast.
+
+For example, suppose I am sending 1 BTC to bc1q257z5t76hedc36wmmzva05890ny3kxd7xfwrgy. First I create a funded psbt with BIP 32 derivation paths to be included::
+
+ $ src/bitcoin-cli -rpcwallet=hwicoldcard walletcreatefundedpsbt '[]' '[{"bc1q257z5t76hedc36wmmzva05890ny3kxd7xfwrgy":1}]' 0 '{"includeWatching":true}' true
+ {
+ "psbt": "cHNidP8BAHECAAAAAU8KWkCU7H4MYBiZHmLey6FavV3L3xLfy4tVEZoubx+2AAAAAAD+////AgDh9QUAAAAAFgAUVTwqL9q+W4jp29iZ19DlfMkbGb78eNcXAAAAABYAFLHuX3WRuPs3ypeQOziNw5qFlBH8AAAAAAABAR8AZc0dAAAAABYAFOHBlVRAplXb3rO39IoSBvhnGZEvIgYCIyDxz3Lnuizva+MtdJPOO9TGoldf5RziYDd63BZWA9QYgDjs2VQAAIABAACAAAAAgAAAAAAAAAAAAAAiAgP0HMQ2K693zCXTCudBUzemDhxLmFGETOnAV7vgDz2r9RiAOOzZVAAAgAEAAIAAAACAAQAAAAAAAAAA",
+ "fee": 0.00002820,
+ "changepos": 1
+ }
+
+
+Now I take the updated psbt and inspect it with ``decodepsbt``::
+
+ $ src/bitcoin-cli decodepsbt cHNidP8BAHECAAAAAU8KWkCU7H4MYBiZHmLey6FavV3L3xLfy4tVEZoubx+2AAAAAAD+////AgDh9QUAAAAAFgAUVTwqL9q+W4jp29iZ19DlfMkbGb78eNcXAAAAABYAFLHuX3WRuPs3ypeQOziNw5qFlBH8AAAAAAABAR8AZc0dAAAAABYAFOHBlVRAplXb3rO39IoSBvhnGZEvIgYCIyDxz3Lnuizva+MtdJPOO9TGoldf5RziYDd63BZWA9QYgDjs2VQAAIABAACAAAAAgAAAAAAAAAAAAAAiAgP0HMQ2K693zCXTCudBUzemDhxLmFGETOnAV7vgDz2r9RiAOOzZVAAAgAEAAIAAAACAAQAAAAAAAAAA
+ {
+ "tx": {
+ "txid": "e51392c82e13bbfe714c73361aff14ac1a1637abf37587a562844ae5a4265adf",
+ "hash": "e51392c82e13bbfe714c73361aff14ac1a1637abf37587a562844ae5a4265adf",
+ "version": 2,
+ "size": 113,
+ "vsize": 113,
+ "weight": 452,
+ "locktime": 0,
+ "vin": [
+ {
+ "txid": "b61f6f2e9a11558bcbdf12dfcb5dbd5aa1cbde621e9918600c7eec94405a0a4f",
+ "vout": 0,
+ "scriptSig": {
+ "asm": "",
+ "hex": ""
+ },
+ "sequence": 4294967294
+ }
+ ],
+ "vout": [
+ {
+ "value": 1.00000000,
+ "n": 0,
+ "scriptPubKey": {
+ "asm": "0 553c2a2fdabe5b88e9dbd899d7d0e57cc91b19be",
+ "hex": "0014553c2a2fdabe5b88e9dbd899d7d0e57cc91b19be",
+ "reqSigs": 1,
+ "type": "witness_v0_keyhash",
+ "addresses": [
+ "bc1q257z5t76hedc36wmmzva05890ny3kxd7xfwrgy"
+ ]
+ }
+ },
+ {
+ "value": 3.99997180,
+ "n": 1,
+ "scriptPubKey": {
+ "asm": "0 b1ee5f7591b8fb37ca97903b388dc39a859411fc",
+ "hex": "0014b1ee5f7591b8fb37ca97903b388dc39a859411fc",
+ "reqSigs": 1,
+ "type": "witness_v0_keyhash",
+ "addresses": [
+ "bc1qk8h97av3hran0j5hjqan3rwrn2zegy0unusy49"
+ ]
+ }
+ }
+ ]
+ },
+ "unknown": {
+ },
+ "inputs": [
+ {
+ "witness_utxo": {
+ "amount": 5.00000000,
+ "scriptPubKey": {
+ "asm": "0 e1c1955440a655dbdeb3b7f48a1206f86719912f",
+ "hex": "0014e1c1955440a655dbdeb3b7f48a1206f86719912f",
+ "type": "witness_v0_keyhash",
+ "address": "bc1qu8qe24zq5e2ahh4nkl6g5ysxlpn3nyf0wyd5k2"
+ }
+ },
+ "bip32_derivs": [
+ {
+ "pubkey": "022320f1cf72e7ba2cef6be32d7493ce3bd4c6a2575fe51ce260377adc165603d4",
+ "master_fingerprint": "8038ecd9",
+ "path": "m/84'/1'/0'/0/0"
+ }
+ ]
+ }
+ ],
+ "outputs": [
+ {
+ },
+ {
+ "bip32_derivs": [
+ {
+ "pubkey": "03f41cc4362baf77cc25d30ae7415337a60e1c4b9851844ce9c057bbe00f3dabf5",
+ "master_fingerprint": "8038ecd9",
+ "path": "m/84'/1'/0'/1/0"
+ }
+ ]
+ }
+ ],
+ "fee": 0.00002820
+ }
+
+Once the transaction has been inspected and everything looks good, the transaction can now be signed using HWI.
+
+::
+
+ $ cd ../HWI
+ $ ./hwi.py -f e5dbc9cb --testnet signtx cHNidP8BAHECAAAAAU8KWkCU7H4MYBiZHmLey6FavV3L3xLfy4tVEZoubx+2AAAAAAD+////AgDh9QUAAAAAFgAUVTwqL9q+W4jp29iZ19DlfMkbGb78eNcXAAAAABYAFLHuX3WRuPs3ypeQOziNw5qFlBH8AAAAAAABAR8AZc0dAAAAABYAFOHBlVRAplXb3rO39IoSBvhnGZEvIgYCIyDxz3Lnuizva+MtdJPOO9TGoldf5RziYDd63BZWA9QYgDjs2VQAAIABAACAAAAAgAAAAAAAAAAAAAAiAgP0HMQ2K693zCXTCudBUzemDhxLmFGETOnAV7vgDz2r9RiAOOzZVAAAgAEAAIAAAACAAQAAAAAAAAAA
+
+Follow the onscreen instructions, check everything, and approve the transaction. The result will look like::
+
+ {"psbt": "cHNidP8BAHECAAAAAU8KWkCU7H4MYBiZHmLey6FavV3L3xLfy4tVEZoubx+2AAAAAAD+////AgDh9QUAAAAAFgAUVTwqL9q+W4jp29iZ19DlfMkbGb78eNcXAAAAABYAFLHuX3WRuPs3ypeQOziNw5qFlBH8AAAAAAABAR8AZc0dAAAAABYAFOHBlVRAplXb3rO39IoSBvhnGZEvIgICIyDxz3Lnuizva+MtdJPOO9TGoldf5RziYDd63BZWA9RIMEUCIQDMECVXsrFK5XbMQn5yVCvm3zWF1kdCgepf3RSqFDDmAAIgQtty07rN4zBWMjd1qVOtkgOHBAlGaO2Se3LkiNsABYcBAQMEAQAAACIGAiMg8c9y57os72vjLXSTzjvUxqJXX+Uc4mA3etwWVgPUGIA47NlUAACAAQAAgAAAAIAAAAAAAAAAAAAAIgID9BzENiuvd8wl0wrnQVM3pg4cS5hRhEzpwFe74A89q/UYgDjs2VQAAIABAACAAAAAgAEAAAAAAAAAAA=="}
+
+We can now take the PSBT, finalize it, and broadcast it with Bitcoin Core
+
+::
+
+ $ cd ../bitcoin
+ $ src/bitcoin-cli finalizepsbt cHNidP8BAHECAAAAAU8KWkCU7H4MYBiZHmLey6FavV3L3xLfy4tVEZoubx+2AAAAAAD+////AgDh9QUAAAAAFgAUVTwqL9q+W4jp29iZ19DlfMkbGb78eNcXAAAAABYAFLHuX3WRuPs3ypeQOziNw5qFlBH8AAAAAAABAR8AZc0dAAAAABYAFOHBlVRAplXb3rO39IoSBvhnGZEvIgICIyDxz3Lnuizva+MtdJPOO9TGoldf5RziYDd63BZWA9RIMEUCIQDMECVXsrFK5XbMQn5yVCvm3zWF1kdCgepf3RSqFDDmAAIgQtty07rN4zBWMjd1qVOtkgOHBAlGaO2Se3LkiNsABYcBAQMEAQAAACIGAiMg8c9y57os72vjLXSTzjvUxqJXX+Uc4mA3etwWVgPUGIA47NlUAACAAQAAgAAAAIAAAAAAAAAAAAAAIgID9BzENiuvd8wl0wrnQVM3pg4cS5hRhEzpwFe74A89q/UYgDjs2VQAAIABAACAAAAAgAEAAAAAAAAAAA==
+ {
+ "hex": "020000000001014f0a5a4094ec7e0c6018991e62decba15abd5dcbdf12dfcb8b55119a2e6f1fb60000000000feffffff0200e1f50500000000160014553c2a2fdabe5b88e9dbd899d7d0e57cc91b19befc78d71700000000160014b1ee5f7591b8fb37ca97903b388dc39a859411fc02483045022100cc102557b2b14ae576cc427e72542be6df3585d6474281ea5fdd14aa1430e600022042db72d3bacde33056323775a953ad92038704094668ed927b72e488db0005870121022320f1cf72e7ba2cef6be32d7493ce3bd4c6a2575fe51ce260377adc165603d400000000",
+ "complete": true
+ }
+ $ src/bitcoin-cli sendrawtransaction 020000000001014f0a5a4094ec7e0c6018991e62decba15abd5dcbdf12dfcb8b55119a2e6f1fb60000000000feffffff0200e1f50500000000160014553c2a2fdabe5b88e9dbd899d7d0e57cc91b19befc78d71700000000160014b1ee5f7591b8fb37ca97903b388dc39a859411fc02483045022100cc102557b2b14ae576cc427e72542be6df3585d6474281ea5fdd14aa1430e600022042db72d3bacde33056323775a953ad92038704094668ed927b72e488db0005870121022320f1cf72e7ba2cef6be32d7493ce3bd4c6a2575fe51ce260377adc165603d400000000
+ e51392c82e13bbfe714c73361aff14ac1a1637abf37587a562844ae5a4265adf
+
+Refilling the keypools
+----------------------
+
+Descriptor wallets will constantly generate new addresses from the imported descriptors.
+It is not necessary to import additional keys or descriptors to refresh the keypool, Bitcoin Core will do so automatically by using the descriptors.
+
+Derivation Path BIP Compliance
+==============================
+
+The instructions above use BIP 84 to derive keys used for P2WPKH addresses (bech32 addresses).
+HWI follows BIPs 44, 84, and 49. By default, descriptors will be for P2WPKH addresses with keys derived at ``m/84h/0h/0h/0`` for normal receiving keys and ``m/84h/0h/0h/1`` for change keys.
+Using the ``--addr-type legacy`` option will result in P2PKH addresses with keys derived at ``m/44h/0h/0h/0`` for normal receiving keys and ``m/44h/0h/0h/1`` for change keys.
+Using the ``--addr-type sh_wit`` option will result in P2SH nested P2WPKH addresses with keys derived at ``m/49h/0h/0h/0`` for normal receiving keys and ``m/49h/0h/0h/1`` for change keys.
+
+To actually get the correct address type when using ``getnewaddress`` from Bitcoin Core, you will need to additionally set ``-addresstype=p2sh-segwit`` and ``-changetype=p2sh-segwit``.
+This can be set in the command line (as shown in the example) or in your bitcoin.conf file.
+
+Alternative derivation paths can also be chosen using the ``--path`` option and specifying your own derivation path.
diff --git a/docs/examples/examples.rst b/docs/examples/examples.rst
new file mode 100644
index 0000000..5dca030
--- /dev/null
+++ b/docs/examples/examples.rst
@@ -0,0 +1,156 @@
+Examples
+********
+
+Example using a Ledger Nano S::
+
+ ./hwi.py enumerate
+ [{"type": "ledger", "path": "IOService:/AppleACPIPlatformExpert/PCI0@0/AppleACPIPCI/XHC1@14/XHC1@14000000/HS02@14200000/Nano S@14200000/Nano S@0/IOUSBHostHIDDevice@14200000,0", "serial_number": "0001"}, {"type": "ledger", "path": "IOService:/AppleACPIPlatformExpert/PCI0@0/AppleACPIPCI/XHC1@14/XHC1@14000000/HS02@14200000/Nano S@14200000/Nano S@1/IOUSBHostHIDDevice@14200000,1", "serial_number": "0001"}]
+
+The OS in this case is macOS v10.13.6 (Darwin Kernel Version 17.7.0). In Linux the
+"path" is shorter.
+
+Extracting xpubs
+================
+
+Bitcoin Core v0.17.0 and later allows you to retrieve the unspent transaction outputs (utxo)
+relevant for a set of `Output Descriptors <https://github.com/bitcoin/bitcoin/blob/master/doc/descriptors.md>`_ with the ``scantxoutset`` RPC call.
+
+To retrieve the outputs relevant for a specific hardware wallet it is
+necessary:
+
+1. to derive the xpub of the hardware wallet until the last hardened level
+ with HWI (because the private key is required)
+2. to use the obtained xpub to compose the output descriptor
+
+These are some schemas used in hardware wallets, with the data necessary to
+build the appropriate output descriptor:
+
++-------------+---------------+--------------------+-------------+
+| Used schema | hardened path | further derivation | Output type |
++=============+===============+====================+=============+
+| BIP44 | m/44h/0h/0h | /0/* and /1/* | pkh() |
++-------------+---------------+--------------------+-------------+
+| BIP49 | m/49h/0h/0h | /0/* and /1/* | sh(wpkh()) |
++-------------+---------------+--------------------+-------------+
+| BIP84 | m/84h/0h/0h | /0/* and /1/* | wpkh() |
++-------------+---------------+--------------------+-------------+
+
+NOTE:
+
+1. We could also use "combo()" in all cases as "Output Type" because it is a
+ "bundle" which includes pk(KEY) and pkh(KEY). If the key is compressed, it
+ also includes wpkh(KEY) and sh(wpkh(KEY)).
+
+2. It is possible to specify how many outputs to search for by setting the
+ maximum index of the derivation with the "range" key. In the examples
+ it is set to 100.
+
+3. The search returns zero outputs (the hardware wallet is empty).
+
+`BIP44 <https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki>`_
+-------------------------------------------------------------------------
+
+1. To obtain the xpub relative to the last hardened level (m/44h/0h/0h)
+
+::
+
+ ./hwi.py -t "ledger" -d "IOService:/AppleACPIPlatformExpert/PCI0@0/AppleACPIPCI/XHC1@14/XHC1@14000000/HS02@14200000/Nano S@14200000/Nano S@0/IOUSBHostHIDDevice@14200000,0" getxpub m/44h/0h/0h
+ => b'e0c4000000'
+ <= b'1b30010208010003'9000
+ => b'f026000000'
+ <= b''6d00
+ => b'e04000000d038000002c8000000080000000'
+ <= b'4104f4b866b49fb76529a076a1c5b25216c1f4b970cb8e3db9874beb15c5371fdb93747fde522d63be4a564dcda8a71c889f5165eac2990cafee9d416141ae8b09c722313667774c7a76697157783146317a653365676850464d58655438666a57466f4b66f9a82310c4530360ec3fee42049fbb7a3c0bfa72fdf2c5b25b09f1c3df21c938'9000
+ => b'e040000009028000002c80000000'
+ <= b'4104280c846650d7771396a679a55b30c558501f0b5554160c1fbd1d7301c845dacc10c256af2c8d6a13ae4a83763fa747c0d4c09cfa60bfc16714e10b0a938a4a6a2231485451557a6535486571334872553755435174564652745a535839615352674a65d62f97789c088a0b0c3ed57754f75273c6696c0d7812c702ca4f2f72c8631c04'9000
+ {"xpub": "xpub6CyidiQae2HF71YigFJqteLsRi9D1EvZJm1Lr4DWWxFVruf3vDSbfyxD9znqVkUTUzc4EdgxDRoHXn64gMbFXQGKXg5nPNfvyVcpuPNn92n"}
+
+2. With this xpub it is possible to extract the relevant UTXOs using the
+``scantxoutset`` RPC call in Bitcoin Core v0.17.0.
+
+::
+
+ bitcoin-cli scantxoutset start '[{"desc":"pkh(xpub6CyidiQae2HF71YigFJqteLsRi9D1EvZJm1Lr4DWWxFVruf3vDSbfyxD9znqVkUTUzc4EdgxDRoHXn64gMbFXQGKXg5nPNfvyVcpuPNn92n/0/*)","range":100},
+ {"desc":"pkh(xpub6CyidiQae2HF71YigFJqteLsRi9D1EvZJm1Lr4DWWxFVruf3vDSbfyxD9znqVkUTUzc4EdgxDRoHXn64gMbFXQGKXg5nPNfvyVcpuPNn92n/1/*)","range":100}]'
+ {
+ "success": true,
+ "searched_items": 49507771,
+ "unspents": [
+ ],
+ "total_amount": 0.00000000
+ }
+
+`BIP49 <https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki>`_
+-------------------------------------------------------------------------
+
+1. To obtain the xpub relative to the last hardened level (m/49h/0h/0h)
+
+::
+
+ ./hwi.py -t "ledger" -d "IOService:/AppleACPIPlatformExpert/PCI0@0/AppleACPIPCI/XHC1@14/XHC1@14000000/HS02@14200000/Nano S@14200000/Nano S@0/IOUSBHostHIDDevice@14200000,0" getxpub m/49h/0h/0h
+ => b'e0c4000000'
+ <= b'1b30010208010003'9000
+ => b'f026000000'
+ <= b''6d00
+ => b'e04000000d03800000318000000080000000'
+ <= b'410437c2c1ebd83155843b3e8528b43b9786a8dc144df151b27677b76443e54b466d46b0d909d07065a2305cbba41709c78d886be37e446352186a682e9a3f9e2adc22314a594538323869434b7043576368665377396832746857377a533469486e4c444444dcdbabc6f75fbe7609bab04beb88566e3bfc98f66ab030d1af2a070f4064ec'9000
+ => b'e040000009028000003180000000'
+ <= b'4104c34926ea569d26e4ca06ccae25fa4332a07df69fb922a73131cfccf6a544aa3309af253eb5cee3caf8ca9a347a9e8d4429ac55b7a13f72aca36ebb51ca0f489e22314e546e3969454c587046324264664b6f326f316265785a72526e75396d65764663b310aae1803b63157ef3bb7394f985126e5f9ad4b3a6bcb118cd97875dc0e1ce'9000
+ {"xpub": "xpub6DP8WTA5cy2qWzdtjMUpLJHkzonepEZytzxFLMzkrcW7U4prscYnmXRQ8BesvMP3iqgQUWisAU6ipXnZw2HnNreEPYJW6TUCAfmwJPyYgG6"}
+
+2. With this xpub it is possible extract the relevant UTXOs using the
+``scantxoutset`` RPC call in Bitcoin Core v0.17.0.
+
+::
+
+ bitcoin-cli scantxoutset start '[{"desc":"sh(wpkh(xpub6DP8WTA5cy2qWzdtjMUpLJHkzonepEZytzxFLMzkrcW7U4prscYnmXRQ8BesvMP3iqgQUWisAU6ipXnZw2HnNreEPYJW6TUCAfmwJPyYgG6/0/*))","range":100},
+ {"desc":"sh(wpkh(xpub6DP8WTA5cy2qWzdtjMUpLJHkzonepEZytzxFLMzkrcW7U4prscYnmXRQ8BesvMP3iqgQUWisAU6ipXnZw2HnNreEPYJW6TUCAfmwJPyYgG6/1/*))","range":100}]'
+ {
+ "success": true,
+ "searched_items": 49507771,
+ "unspents": [
+ ],
+ "total_amount": 0.00000000
+ }
+
+`BIP84 <https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki>`_
+-------------------------------------------------------------------------
+
+1. To obtain the xpub relative to the last hardened level (m/84h/0h/0h)
+
+::
+
+ ./hwi.py -t "ledger" -d "IOService:/AppleACPIPlatformExpert/PCI0@0/AppleACPIPCI/XHC1@14/XHC1@14000000/HS02@14200000/Nano S@14200000/Nano S@0/IOUSBHostHIDDevice@14200000,0" getxpub m/84h/0h/0h
+ => b'e0c4000000'
+ <= b'1b30010208010003'9000
+ => b'f026000000'
+ <= b''6d00
+ => b'e04000000d03800000548000000080000000'
+ <= b'4104c79ce10d23b84ec27996e02b83964ec1953fb474ba358e70de62a09cee28dd6590f76b105fb2707c74bbefff0b4aea4156364dd813826848e8c3240d286781b722314270736737486455576a483753704535386e6d62654642773367595a554536776b2017f28f680893adfc004f5ec6db3654577c19b463326329b5d1d90de8dc24cf'9000
+ => b'e040000009028000005480000000'
+ <= b'410483472c03c4157d1b0f8ad98c9391dfbfc820e0180d683658ed863609da5f866aafa260048bc42cd97cb997479fd2619c5d160af68a442a80567b41fe3e763fbe22314e5531544d796971575871367278746375424a3433376d4e75736d745a73554769c03458c3a331489e3271a24a76f4ab024e040e7de7b5e88d8ce058d414f565c2'9000
+ {"xpub": "xpub6DP9afdc7qsz7s7mwAvciAR2dV6vPC3gyiQbqKDzDcPAq3UQChKPimHc3uCYfTTkpoXdwRTFnVTBdFpM9ysbf6KV34uMqkD3zXr6FzkJtcB"}
+
+2. With this xpub it is possible extract the relevant UTXOs using the
+``scantxoutset`` RPC call in Bitcoin Core v0.17.0.
+
+::
+
+ bitcoin-cli scantxoutset start '[{"desc":"wpkh(xpub6DP9afdc7qsz7s7mwAvciAR2dV6vPC3gyiQbqKDzDcPAq3UQChKPimHc3uCYfTTkpoXdwRTFnVTBdFpM9ysbf6KV34uMqkD3zXr6FzkJtcB/0/*)","range":100},
+ {"desc":"wpkh(xpub6DP9afdc7qsz7s7mwAvciAR2dV6vPC3gyiQbqKDzDcPAq3UQChKPimHc3uCYfTTkpoXdwRTFnVTBdFpM9ysbf6KV34uMqkD3zXr6FzkJtcB/1/*)","range":100}]'
+ {
+ "success": true,
+ "searched_items": 49507771,
+ "unspents": [
+ ],
+ "total_amount": 0.00000000
+ }
+
+Binary format handling
+======================
+
+The input and output format supported by HWI is base64, which is prescribed by BIP174 as the string format. Note that the PSBT standard also allows for binary formatting when stored as a file. There is no direct support within HWI, but this can be easily accomplished using common utilities. A bash command-line example is detailed below, where the PSBT binary file is stored in ``example.psbt`` and only the common utilities ``base64`` and ``jq`` are required:
+
+::
+
+ cat example.psbt | base64 --wrap=0 | ./hwi.py -t ledger --stdin signtx | jq .[] --raw-output | base64 -d > example_result.psbt
diff --git a/docs/examples/index.rst b/docs/examples/index.rst
new file mode 100644
index 0000000..f95cedb
--- /dev/null
+++ b/docs/examples/index.rst
@@ -0,0 +1,9 @@
+Example Usage
+=============
+
+.. toctree::
+ :maxdepth: 1
+
+ bitcoin-core-usage
+ examples
+ walkthrough/walkthrough
diff --git a/docs/examples/walkthrough/Screenshot01_HWI_Empty-State.png b/docs/examples/walkthrough/Screenshot01_HWI_Empty-State.png
new file mode 100644
index 0000000..e6dbaaf
Binary files /dev/null and b/docs/examples/walkthrough/Screenshot01_HWI_Empty-State.png differ
diff --git a/docs/examples/walkthrough/Screenshot02_HWI_HWW-Selected.png b/docs/examples/walkthrough/Screenshot02_HWI_HWW-Selected.png
new file mode 100644
index 0000000..c94fec2
Binary files /dev/null and b/docs/examples/walkthrough/Screenshot02_HWI_HWW-Selected.png differ
diff --git a/docs/examples/walkthrough/Screenshot03-Core-Initial-Wallet-Overview.png b/docs/examples/walkthrough/Screenshot03-Core-Initial-Wallet-Overview.png
new file mode 100644
index 0000000..37de9f7
Binary files /dev/null and b/docs/examples/walkthrough/Screenshot03-Core-Initial-Wallet-Overview.png differ
diff --git a/docs/examples/walkthrough/Screenshot04_HWI_Address-Display-Request.png b/docs/examples/walkthrough/Screenshot04_HWI_Address-Display-Request.png
new file mode 100644
index 0000000..c6ae4d8
Binary files /dev/null and b/docs/examples/walkthrough/Screenshot04_HWI_Address-Display-Request.png differ
diff --git a/docs/examples/walkthrough/Screenshot05_HWW_Display-Receive-Address.png b/docs/examples/walkthrough/Screenshot05_HWW_Display-Receive-Address.png
new file mode 100644
index 0000000..94d6772
Binary files /dev/null and b/docs/examples/walkthrough/Screenshot05_HWW_Display-Receive-Address.png differ
diff --git a/docs/examples/walkthrough/Screenshot06_HWI_Address-Display-Response.png b/docs/examples/walkthrough/Screenshot06_HWI_Address-Display-Response.png
new file mode 100644
index 0000000..b0a78b3
Binary files /dev/null and b/docs/examples/walkthrough/Screenshot06_HWI_Address-Display-Response.png differ
diff --git a/docs/examples/walkthrough/Screenshot07_Core_Console-getaddressinfo.png b/docs/examples/walkthrough/Screenshot07_Core_Console-getaddressinfo.png
new file mode 100644
index 0000000..48dc221
Binary files /dev/null and b/docs/examples/walkthrough/Screenshot07_Core_Console-getaddressinfo.png differ
diff --git a/docs/examples/walkthrough/Screenshot08_HWI_Address-Display-Request.png b/docs/examples/walkthrough/Screenshot08_HWI_Address-Display-Request.png
new file mode 100644
index 0000000..e3a5dc8
Binary files /dev/null and b/docs/examples/walkthrough/Screenshot08_HWI_Address-Display-Request.png differ
diff --git a/docs/examples/walkthrough/Screenshot09_HWW_Display-Change-Address.png b/docs/examples/walkthrough/Screenshot09_HWW_Display-Change-Address.png
new file mode 100644
index 0000000..1e645ad
Binary files /dev/null and b/docs/examples/walkthrough/Screenshot09_HWW_Display-Change-Address.png differ
diff --git a/docs/examples/walkthrough/Screenshot10_HWI_Address-Display-Response.png b/docs/examples/walkthrough/Screenshot10_HWI_Address-Display-Response.png
new file mode 100644
index 0000000..fe7e5a2
Binary files /dev/null and b/docs/examples/walkthrough/Screenshot10_HWI_Address-Display-Response.png differ
diff --git a/docs/examples/walkthrough/Screenshot11_Core_Console-getaddressinfo.png b/docs/examples/walkthrough/Screenshot11_Core_Console-getaddressinfo.png
new file mode 100644
index 0000000..32c3b4c
Binary files /dev/null and b/docs/examples/walkthrough/Screenshot11_Core_Console-getaddressinfo.png differ
diff --git a/docs/examples/walkthrough/Screenshot12_Core_Send-Tab.png b/docs/examples/walkthrough/Screenshot12_Core_Send-Tab.png
new file mode 100644
index 0000000..70e9a6d
Binary files /dev/null and b/docs/examples/walkthrough/Screenshot12_Core_Send-Tab.png differ
diff --git a/docs/examples/walkthrough/Screenshot13_Core_Create-Unsigned-Tx.png b/docs/examples/walkthrough/Screenshot13_Core_Create-Unsigned-Tx.png
new file mode 100644
index 0000000..7548e80
Binary files /dev/null and b/docs/examples/walkthrough/Screenshot13_Core_Create-Unsigned-Tx.png differ
diff --git a/docs/examples/walkthrough/Screenshot14_Core_Paste-PSBT-to-Clipboard.png b/docs/examples/walkthrough/Screenshot14_Core_Paste-PSBT-to-Clipboard.png
new file mode 100644
index 0000000..6a65886
Binary files /dev/null and b/docs/examples/walkthrough/Screenshot14_Core_Paste-PSBT-to-Clipboard.png differ
diff --git a/docs/examples/walkthrough/Screenshot15_HWI_Empty-PSBT.png b/docs/examples/walkthrough/Screenshot15_HWI_Empty-PSBT.png
new file mode 100644
index 0000000..3fc20ab
Binary files /dev/null and b/docs/examples/walkthrough/Screenshot15_HWI_Empty-PSBT.png differ
diff --git a/docs/examples/walkthrough/Screenshot16_HWI_Prepare-PSBT-signing.png b/docs/examples/walkthrough/Screenshot16_HWI_Prepare-PSBT-signing.png
new file mode 100644
index 0000000..4681077
Binary files /dev/null and b/docs/examples/walkthrough/Screenshot16_HWI_Prepare-PSBT-signing.png differ
diff --git a/docs/examples/walkthrough/Screenshot17_HWW_Confirm-Amount-Destination.png b/docs/examples/walkthrough/Screenshot17_HWW_Confirm-Amount-Destination.png
new file mode 100644
index 0000000..6962ac2
Binary files /dev/null and b/docs/examples/walkthrough/Screenshot17_HWW_Confirm-Amount-Destination.png differ
diff --git a/docs/examples/walkthrough/Screenshot18_HWW_Confirm-Locktime.png b/docs/examples/walkthrough/Screenshot18_HWW_Confirm-Locktime.png
new file mode 100644
index 0000000..5cad208
Binary files /dev/null and b/docs/examples/walkthrough/Screenshot18_HWW_Confirm-Locktime.png differ
diff --git a/docs/examples/walkthrough/Screenshot19_HWW_Confirm-Amount-Fees.png b/docs/examples/walkthrough/Screenshot19_HWW_Confirm-Amount-Fees.png
new file mode 100644
index 0000000..2816e19
Binary files /dev/null and b/docs/examples/walkthrough/Screenshot19_HWW_Confirm-Amount-Fees.png differ
diff --git a/docs/examples/walkthrough/Screenshot20_Core_Console_getblockcount.png b/docs/examples/walkthrough/Screenshot20_Core_Console_getblockcount.png
new file mode 100644
index 0000000..8c9df4e
Binary files /dev/null and b/docs/examples/walkthrough/Screenshot20_Core_Console_getblockcount.png differ
diff --git a/docs/examples/walkthrough/Screenshot21_HWI_Show-Signed-PSBT.png b/docs/examples/walkthrough/Screenshot21_HWI_Show-Signed-PSBT.png
new file mode 100644
index 0000000..683998e
Binary files /dev/null and b/docs/examples/walkthrough/Screenshot21_HWI_Show-Signed-PSBT.png differ
diff --git a/docs/examples/walkthrough/Screenshot22_Core_Broadcast-Signed-PSBT.png b/docs/examples/walkthrough/Screenshot22_Core_Broadcast-Signed-PSBT.png
new file mode 100644
index 0000000..91148e9
Binary files /dev/null and b/docs/examples/walkthrough/Screenshot22_Core_Broadcast-Signed-PSBT.png differ
diff --git a/docs/examples/walkthrough/Screenshot23_Core_Transactions-Tab.png b/docs/examples/walkthrough/Screenshot23_Core_Transactions-Tab.png
new file mode 100644
index 0000000..98de1b8
Binary files /dev/null and b/docs/examples/walkthrough/Screenshot23_Core_Transactions-Tab.png differ
diff --git a/docs/examples/walkthrough/Screenshot24_Core_Transaction-Details.png b/docs/examples/walkthrough/Screenshot24_Core_Transaction-Details.png
new file mode 100644
index 0000000..45541d5
Binary files /dev/null and b/docs/examples/walkthrough/Screenshot24_Core_Transaction-Details.png differ
diff --git a/docs/examples/walkthrough/Screenshot25_Core_Console-gettransaction.png b/docs/examples/walkthrough/Screenshot25_Core_Console-gettransaction.png
new file mode 100644
index 0000000..a2be04e
Binary files /dev/null and b/docs/examples/walkthrough/Screenshot25_Core_Console-gettransaction.png differ
diff --git a/docs/examples/walkthrough/walkthrough.rst b/docs/examples/walkthrough/walkthrough.rst
new file mode 100644
index 0000000..4520853
--- /dev/null
+++ b/docs/examples/walkthrough/walkthrough.rst
@@ -0,0 +1,188 @@
+Walkthrough: Using a Hardware Wallet with Bitcoin Core Wallet
+******************************************************************
+
+Summary: On this page we describe step-by-step and show in screenshots how to use a hardware wallet and HWI
+together with a Bitcoin Core Wallet. As hardware wallet example we have used a Trezor.
+
+Create a watch-only Bitcoin Core wallet for Trezor
+==================================================
+
+Create your watch-only Bitcoin Core Wallet as described in `Using Bitcoin Core with Hardware Wallets <../bitcoin-core-usage.html>`_.
+You find all the details well described in this link. But in summary, one opens a terminal and runs ``bitcoind``. E.g.
+
+::
+
+ bitcoind -testnet -datadir=$HOME/.bitcoin-testnet
+
+for a testnet ``bitcoind`` daemon, or
+
+::
+
+ bitcoind
+
+for a mainnet, i.e. regular, ``bitcoind`` daemon.
+
+Then in another terminal run commands similar to these, adapted to your environment:
+
+::
+
+ hwi.py enumerate # this shows you the fingerprint of your hardware wallet
+ FINGERPRINT_TESTNET="yourHardwareWalletFingerprint" # shown by "hwi enumerate"
+ # in this example we use SEGWIT BECH32 ADDRESSES
+ DERIVATIONPATH_TESTNET=1 # testnet uses derivation paths like m/84h/1h/0h/0/* and m/84h/1h/0h/1/*
+ DERIVATIONPATH_MAINNET=0 # mainnet uses derivation paths like m/84h/0h/0h/0/* and m/84h/0h/0h/1/*
+ # if the mainnet path is used on testnet, it will work too, but Trezor device gives warnings
+ # of unknown address on Trezor display. This is not recommended. Use the correct derivation path
+ # for the corresponding network!
+ wallet=wallet.test
+ rec=$(hwi --testnet -f $FINGERPRINT_TESTNET getkeypool --addr-type wit --path m/84h/${DERIVATIONPATH_TESTNET}h/0h/0/* --keypool 0 1000)
+ chg=$(hwi --testnet -f $FINGERPRINT_TESTNET getkeypool --addr-type wit --path m/84h/${DERIVATIONPATH_TESTNET}h/0h/1/* --keypool --internal 0 1000)
+ bitcoin-cli -testnet createwallet "$wallet" true
+ bitcoin-cli -testnet -rpcwallet="$wallet" importmulti "$rec"
+ bitcoin-cli -testnet -rpcwallet="$wallet" importmulti "$chg"
+ echo "If the hardware wallet has been used before and holds funds then you should rescan. Rescanning might take 30 minutes."
+ bitcoin-cli -testnet -rpcwallet="$wallet" rescanblockchain # full rescan
+ # after rescan unload wallet
+ bitcoin-cli -testnet -rpcwallet="$wallet" unloadwallet
+
+This script needs to be adapted to your needs. If you are creating a wallet for mainnet get rid of ``-testnet`` and ``--testnet`` and
+use ``DERIVATIONPATH_MAINNET`` instead of ``DERIVATIONPATH_TESTNET``. Adapt the derivation paths to your needs.
+Now that the watch-only Bitcoin Core wallet has been created, stop ``bitcoind`` with control-C. We are ready to use the wallet.
+
+Send funds with Bitcoin Core and Trezor using HWI
+=================================================
+
+* our example does everything on the Bitcoin testnet, so watch out, your addresses and paths will differ
+* TREZOR: plug in your hardware wallet, e.g. your Trezor, put in the PIN if any
+* HWI: type ``hwi-qt.py --testnet`` to start HWI GUI for testnet (type ``hwi-qt.py`` to start HWI GUI for mainnet)
+
+.. image:: Screenshot01_HWI_Empty-State.png
+
+* TREZOR: your hardware wallet, e.g. Trezor, might prompt you for a passphrase, enter passphrase on hardware wallet (if any)
+* HWI: select your hardware wallet in HWI GUI
+
+.. image:: Screenshot02_HWI_HWW-Selected.png
+
+* CORE: start Bitcoin Core wallet, e.g. ``bitcoin-qt -testnet`` (or ``bitcoin-qt`` for mainnet)
+
+.. image:: Screenshot03-Core-Initial-Wallet-Overview.png
+
+* on the very first run it might be a good idea to verify that the wallet has been created correctly
+* on first run **verify your wallet** (optional)
+* HWI: HWI GUI -> "Display Address", since we use BECH32 address, select "P2WPKH",
+ enter "m/84h/1h/0h/0/0" (testnet derivation path) (or "m/84h/0h/0h/0/0" on mainnet).
+ This path represents the first receiving address. Click "Go".
+ In our example, it shows address "tb1q0r2gn9wzfjm5j5zshx5yp5342h928c8pmllfep".
+
+.. image:: Screenshot04_HWI_Address-Display-Request.png
+
+.. image:: Screenshot05_HWW_Display-Receive-Address.png
+
+.. image:: Screenshot06_HWI_Address-Display-Response.png
+
+* CORE: In Core Wallet, open "Console", enter ``getaddressinfo tb1q0r2gn9wzfjm5j5zshx5yp5342h928c8pmllfep``,
+ observe these values:
+ It is crucial that ``solvable`` shows as ``true``!
+
+ * "solvable": true,
+ * "iswatchonly": true,
+ * "hdkeypath": "m/84'/1'/0'/0/0",
+
+.. image:: Screenshot07_Core_Console-getaddressinfo.png
+
+* HWI: In HWI GUI main window click "Display Address", since we use BECH32 address,
+ select "P2WPKH", enter "m/84h/1h/0h/1/0" (testnet derivation path) (or "m/84h/0h/0h/1/0" on mainnet).
+ This path represents the first change address. Click "Go".
+ In our example it shows address "tb1qca3u0ka22c934jfqw7gjr9vg4gwwjldpzatrh5".
+
+.. image:: Screenshot08_HWI_Address-Display-Request.png
+
+.. image:: Screenshot09_HWW_Display-Change-Address.png
+
+.. image:: Screenshot10_HWI_Address-Display-Response.png
+
+* CORE: In Core Wallet, open "Console", enter ``getaddressinfo tb1qca3u0ka22c934jfqw7gjr9vg4gwwjldpzatrh5``,
+ observe these values:
+ It is crucial that ``solvable`` shows as ``true``!
+
+ * "solvable": true,
+ * "iswatchonly": true,
+ * "hdkeypath": "m/84'/1'/0'/1/0",
+
+.. image:: Screenshot11_Core_Console-getaddressinfo.png
+
+* If you see the same addresses for the same paths on Trezor, in HWI and in Bitcoin Core Wallet
+ you can rest assured that the wallet has been created correctly and
+ that the Bitcoin Core wallet corresponds to your Trezor device.
+
+* Now let us **send funds**.
+
+* CORE: To send funds, open the "Send" tab in Bitcoin Core Wallet,
+ then select input, amount, fees, etc. Once satisfied, click "Create Unsigned",
+ verify any displayed information, then click "Create Unsigned" again.
+ The PSBT (Partially Signed Bitcoin Transaction) is now on the clipboard.
+
+.. image:: Screenshot12_Core_Send-Tab.png
+
+.. image:: Screenshot13_Core_Create-Unsigned-Tx.png
+
+.. image:: Screenshot14_Core_Paste-PSBT-to-Clipboard.png
+
+* HWI: In HWI GUI main window click "Sign PSBT", then paste PSBT from clipboard
+ into the above text field. After paste, click "Sign PSBT".
+
+.. image:: Screenshot15_HWI_Empty-PSBT.png
+
+.. image:: Screenshot16_HWI_Prepare-PSBT-signing.png
+
+* TREZOR: verify signing on Trezor, accept operation on Trezor if all is correct
+
+.. image:: Screenshot17_HWW_Confirm-Amount-Destination.png
+
+.. image:: Screenshot18_HWW_Confirm-Locktime.png
+
+.. image:: Screenshot19_HWW_Confirm-Amount-Fees.png
+
+* CORE: Trezor prints blockheight of locktime which can optionally be verified in
+ Bitcoin Core Wallet (Console -> ``getblockcount``). For a simple send the locktime
+ is now and you should get the current blockheight.
+
+.. image:: Screenshot20_Core_Console_getblockcount.png
+
+* HWI: upon accepting on Trezor, the HWI bottom text area is filled.
+ Select the bottom output, and copy full output from the bottom text area to the clipboard
+
+.. image:: Screenshot21_HWI_Show-Signed-PSBT.png
+
+* CORE: In Bitcoin Core Wallet, go to the pull-down menu: select File -> Load from Clipboard.
+
+.. image:: Screenshot22_Core_Broadcast-Signed-PSBT.png
+
+* CORE: In Core Wallet, visually verify again, then click "Broadcast Tx" button.
+ Once broadcasted, click "Close".
+ The funds have been sent to the mempool awaiting confirmations on the Bitcoin network.
+
+* CORE: In Core Wallet, go to "Transactions" tab. Here you can find the just
+ sent transaction in the top line. Wait for confirmations.
+
+.. image:: Screenshot23_Core_Transactions-Tab.png
+
+* CORE: Optionally double click transaction to see transaction details.
+
+.. image:: Screenshot24_Core_Transaction-Details.png
+
+* CORE: Optionally, one can also see the transaction details in the
+ Console -> ``gettransaction 58d9dccd190250742c47733f3c0f0d33075d65621196434f163f92b69847843f``
+
+.. image:: Screenshot25_Core_Console-gettransaction.png
+
+* HWI: close HWI GUI
+* CORE: close Core wallet
+* you are done! Pat yourself on the shoulder ;)
+
+Versions Used
+=============
+
+* This walkthrough was done in January 2021
+* HWI version 2.0.0-dev
+* Bitcoin 0.21.0
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 0000000..5b5039e
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,19 @@
+Welcome to Hardware Wallet Interface's documentation!
+=====================================================
+
+.. toctree::
+ :maxdepth: 2
+ :caption: Contents:
+
+ usage/index
+ devices/index
+ development/index
+ examples/index
+
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
diff --git a/docs/requirements.txt b/docs/requirements.txt
new file mode 100644
index 0000000..4b05f4d
--- /dev/null
+++ b/docs/requirements.txt
@@ -0,0 +1,3 @@
+sphinxcontrib-autoprogram>=0.1.5
+sphinx>=3.2.1
+sphinx_rtd_theme>=1.0.0
diff --git a/docs/usage/api-usage.rst b/docs/usage/api-usage.rst
new file mode 100644
index 0000000..2ed097e
--- /dev/null
+++ b/docs/usage/api-usage.rst
@@ -0,0 +1,21 @@
+API Usage
+=========
+
+The library API for use by projects importing ``hwilib`` can be found here.
+
+.. automodule:: hwilib.hwwclient
+ :members:
+.. automodule:: hwilib.commands
+ :members:
+.. automodule:: hwilib.errors
+ :members:
+.. automodule:: hwilib.udevinstaller
+ :members:
+.. automodule:: hwilib.psbt
+ :members:
+.. automodule:: hwilib.descriptor
+ :members:
+.. automodule:: hwilib.key
+ :members:
+.. automodule:: hwilib.common
+ :members:
diff --git a/docs/usage/cli-usage.rst b/docs/usage/cli-usage.rst
new file mode 100644
index 0000000..9d2a71c
--- /dev/null
+++ b/docs/usage/cli-usage.rst
@@ -0,0 +1,11 @@
+Command Line Usage
+******************
+
+HWI is primarily used from the command line.
+Users can use the ``hwi`` command directly, or the HWI self-contained binaries can be distributed with third party software and executed by the software.
+
+The usage of ``hwi`` can be found with ``hwi --help``.
+
+.. autoprogram:: hwilib._cli:get_parser()
+ :prog: hwi
+ :groups:
diff --git a/docs/usage/index.rst b/docs/usage/index.rst
new file mode 100644
index 0000000..85a3f6c
--- /dev/null
+++ b/docs/usage/index.rst
@@ -0,0 +1,9 @@
+Usage
+*****
+
+.. toctree::
+ :maxdepth: 2
+
+ installation
+ cli-usage
+ api-usage
diff --git a/docs/usage/installation.rst b/docs/usage/installation.rst
new file mode 100644
index 0000000..e1251c0
--- /dev/null
+++ b/docs/usage/installation.rst
@@ -0,0 +1,44 @@
+Installation
+************
+
+HWI is distributed in 2 different ways:
+
+1. Self-contained executable binaries
+2. Python package
+
+Binaries
+========
+
+The self-contained binaries are available for download from the `releases page <https://github.com/bitcoin-core/HWI/releases>`_.
+
+Download and extract the package for your operating system and architecture.
+The ``hwi`` binary (``hwi.exe`` for Windows) is a command line tool and executed from the terminal (command prompt in Windows).
+The ``hwi-qt`` binary (``hwi-qt.exe`` for Windows) is a GUI tool and can be executed as any typical application.
+
+Python Package
+==============
+
+The python packages are distributed both from the `releases page <https://github.com/bitcoin-core/HWI/releases>`_ and from `PyPi <https://pypi.org/project/hwi/>`_.
+
+In either case, make sure that you have installed ``pip`` and that it is update to date.
+
+From Releases
+-------------
+
+Download either the Python wheel ``hwi-<version>-py3-none-any.whl`` or the source package ``hwi-<version>.tar.gz``.
+It is recommended to use the wheel over the source package unless your Python installation does not support wheels.
+
+Install the downloaded file using ``pip``. For example::
+
+ pip install hwi-1.1.2-py3-none-any.whl
+
+or::
+
+ pip install hwi-1.1.2.tar.gz
+
+From PyPI
+---------
+
+As HWI is also uploaded to PyPi, it can be installed with::
+
+ pip install hwi
diff --git a/hwi-qt.py b/hwi-qt.py
new file mode 100755
index 0000000..1da3903
--- /dev/null
+++ b/hwi-qt.py
@@ -0,0 +1,7 @@
+#! /usr/bin/env python3
+
+if __name__ == '__main__':
+ from hwilib._gui import main
+ main()
+else:
+ raise ImportError('hwi-qt is not importable. Import hwilib instead')
diff --git a/hwi-qt.spec b/hwi-qt.spec
new file mode 100644
index 0000000..f35fb73
--- /dev/null
+++ b/hwi-qt.spec
@@ -0,0 +1,48 @@
+# -*- mode: python ; coding: utf-8 -*-
+
+import platform
+import subprocess
+
+block_cipher = None
+
+def get_libusb_path():
+ if platform.system() == 'Windows':
+ return 'c:/python3/libusb-1.0.dll'
+ if platform.system() == 'Darwin':
+ proc = subprocess.Popen(['brew', '--prefix', 'libusb'], stdout=subprocess.PIPE)
+ prefix = proc.communicate()[0].rstrip().decode()
+ return os.path.join(prefix, "lib", "libusb-1.0.dylib")
+ if platform.system() == 'Linux':
+ for lib_dir in ['/lib/x86_64-linux-gnu', '/usr/lib64', '/usr/lib', '/lib']:
+ libusb_path = os.path.join(lib_dir, 'libusb-1.0.so.0')
+ if os.path.exists(libusb_path):
+ return libusb_path
+ raise RuntimeError(f"Unsupported platform: {platform.system()}")
+
+a = Analysis(['hwi-qt.py'],
+ binaries=[(get_libusb_path(), '.')],
+ datas=[],
+ hiddenimports=[],
+ hookspath=['contrib/pyinstaller-hooks/'],
+ runtime_hooks=[],
+ excludes=[],
+ win_no_prefer_redirects=False,
+ win_private_assemblies=False,
+ cipher=block_cipher,
+ noarchive=False)
+pyz = PYZ(a.pure, a.zipped_data,
+ cipher=block_cipher)
+exe = EXE(pyz,
+ a.scripts,
+ a.binaries,
+ a.zipfiles,
+ a.datas,
+ [],
+ name='hwi-qt',
+ debug=False,
+ bootloader_ignore_signals=False,
+ strip=False,
+ upx=True,
+ upx_exclude=[],
+ runtime_tmpdir=None,
+ console=False )
diff --git a/hwi.py b/hwi.py
new file mode 100755
index 0000000..7a59887
--- /dev/null
+++ b/hwi.py
@@ -0,0 +1,9 @@
+#! /usr/bin/env python3
+
+# Hardware wallet interaction script
+
+if __name__ == '__main__':
+ from hwilib._cli import main
+ main()
+else:
+ raise ImportError('hwi is not importable. Import hwilib instead')
diff --git a/hwi.spec b/hwi.spec
new file mode 100644
index 0000000..6ad8d38
--- /dev/null
+++ b/hwi.spec
@@ -0,0 +1,51 @@
+# -*- mode: python -*-
+import platform
+import subprocess
+import os
+
+block_cipher = None
+
+def get_libusb_path():
+ if platform.system() == "Windows":
+ return "c:/python3/libusb-1.0.dll"
+ if platform.system() == "Darwin":
+ proc = subprocess.Popen(["brew", "--prefix", "libusb"], stdout=subprocess.PIPE)
+ prefix = proc.communicate()[0].rstrip().decode()
+ return os.path.join(prefix, "lib", "libusb-1.0.dylib")
+ if platform.system() == "Linux":
+ for lib_dir in ["/lib/x86_64-linux-gnu", "/lib/aarch64-linux-gnu", "/usr/lib64", "/lib64" "/usr/lib", "/lib"]:
+ libusb_path = os.path.join(lib_dir, "libusb-1.0.so.0")
+ if os.path.exists(libusb_path):
+ return libusb_path
+ raise RuntimeError(f"Unsupported platform: {platform.system()}")
+
+a = Analysis(['hwi.py'],
+ binaries=[(get_libusb_path(), '.')],
+ datas=[],
+ hiddenimports=[],
+ hookspath=['contrib/pyinstaller-hooks/'],
+ runtime_hooks=[],
+ excludes=[],
+ win_no_prefer_redirects=False,
+ win_private_assemblies=False,
+ cipher=block_cipher,
+ noarchive=False)
+
+if platform.system() == 'Linux':
+ a.datas += Tree('hwilib/udev', prefix='hwilib/udev')
+
+pyz = PYZ(a.pure, a.zipped_data,
+ cipher=block_cipher)
+exe = EXE(pyz,
+ a.scripts,
+ a.binaries,
+ a.zipfiles,
+ a.datas,
+ [],
+ name='hwi',
+ debug=False,
+ bootloader_ignore_signals=False,
+ strip=False,
+ upx=True,
+ runtime_tmpdir=None,
+ console=True )
diff --git a/hwilib/__init__.py b/hwilib/__init__.py
new file mode 100644
index 0000000..f5f41e5
--- /dev/null
+++ b/hwilib/__init__.py
@@ -0,0 +1 @@
+__version__ = "3.1.0"
diff --git a/hwilib/_base58.py b/hwilib/_base58.py
new file mode 100644
index 0000000..5d92c28
--- /dev/null
+++ b/hwilib/_base58.py
@@ -0,0 +1,177 @@
+"""
+Base 58 conversion utilities
+****************************
+"""
+
+#
+# base58.py
+# Original source: git://github.com/joric/brutus.git
+# which was forked from git://github.com/samrushing/caesure.git
+#
+# Distributed under the MIT/X11 software license, see the accompanying
+# file COPYING or http://www.opensource.org/licenses/mit-license.php.
+#
+
+from binascii import hexlify, unhexlify
+from typing import List
+
+from .common import hash256
+from .errors import BadArgumentError
+
+
+b58_digits: str = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
+
+
+def encode(b: bytes) -> str:
+ """
+ Encode bytes to a base58-encoded string
+
+ :param b: Bytes to encode
+ :return: Base58 encoded string of ``b``
+ """
+
+ # Convert big-endian bytes to integer
+ n: int = int('0x0' + hexlify(b).decode('utf8'), 16)
+
+ # Divide that integer into base58
+ temp: List[str] = []
+ while n > 0:
+ n, r = divmod(n, 58)
+ temp.append(b58_digits[r])
+ res: str = ''.join(temp[::-1])
+
+ # Encode leading zeros as base58 zeros
+ czero: int = 0
+ pad: int = 0
+ for c in b:
+ if c == czero:
+ pad += 1
+ else:
+ break
+ return b58_digits[0] * pad + res
+
+def decode(s: str) -> bytes:
+ """
+ Decode a base58-encoding string, returning bytes
+
+ :param s: Base48 string to decode
+ :return: Bytes encoded by ``s``
+ """
+ if not s:
+ return b''
+
+ # Convert the string to an integer
+ n: int = 0
+ for c in s:
+ n *= 58
+ if c not in b58_digits:
+ raise BadArgumentError('Character %r is not a valid base58 character' % c)
+ digit = b58_digits.index(c)
+ n += digit
+
+ # Convert the integer to bytes
+ h: str = '%x' % n
+ if len(h) % 2:
+ h = '0' + h
+ res = unhexlify(h.encode('utf8'))
+
+ # Add padding back.
+ pad = 0
+ for c in s[:-1]:
+ if c == b58_digits[0]:
+ pad += 1
+ else:
+ break
+ return b'\x00' * pad + res
+
+def decode_check(s: str) -> bytes:
+ """
+ Decode a Base58Check encoded string, returning bytes
+
+ :param s: Base58 string to decode
+ :return: Bytes encoded by ``s``
+ """
+ data = decode(s)
+ payload = data[:-4]
+ checksum = data[-4:]
+ calc_checksum = hash256(payload)
+ if checksum != calc_checksum[:4]:
+ raise ValueError("Invalid checksum")
+ return payload
+
+def encode_check(b: bytes) -> str:
+ checksum = hash256(b)[0:4]
+ data = b + checksum
+ return encode(data)
+
+def get_xpub_fingerprint(s: str) -> bytes:
+ """
+ Get the parent fingerprint from an extended public key
+
+ :param s: The extended pubkey
+ :return: The parent fingerprint bytes
+ """
+ data = decode(s)
+ fingerprint = data[5:9]
+ return fingerprint
+
+def get_xpub_fingerprint_hex(xpub: str) -> str:
+ """
+ Get the parent fingerprint as a hex string from an extended public key
+
+ :param s: The extended pubkey
+ :return: The parent fingerprint as a hex string
+ """
+ data = decode(xpub)
+ fingerprint = data[5:9]
+ return hexlify(fingerprint).decode()
+
+def to_address(b: bytes, version: bytes) -> str:
+ """
+ Base58 Check Encode the data with the version number.
+ Used to encode legacy style addresses.
+
+ :param b: The data to encode
+ :param version: The version number to encode with
+ :return: The Base58 Check Encoded string
+ """
+ data = version + b
+ checksum = hash256(data)[0:4]
+ data += checksum
+ return encode(data)
+
+def xpub_to_pub_hex(xpub: str) -> str:
+ """
+ Get the public key as a string from the extended public key.
+
+ :param xpub: The extended pubkey
+ :return: The pubkey hex string
+ """
+ data = decode(xpub)
+ pubkey = data[-37:-4]
+ return hexlify(pubkey).decode()
+
+
+def xpub_to_xonly_pub_hex(xpub: str) -> str:
+ """
+ Get the public key as a string from the extended public key.
+
+ :param xpub: The extended pubkey
+ :return: The pubkey hex string
+ """
+ data = decode(xpub)
+ pubkey = data[-36:-4]
+ return hexlify(pubkey).decode()
+
+
+def xpub_main_2_test(xpub: str) -> str:
+ """
+ Convert an extended pubkey from mainnet version to testnet version.
+
+ :param xpub: The extended pubkey
+ :return: The extended pubkey re-encoded using testnet version bytes
+ """
+ data = decode(xpub)
+ test_data = b'\x04\x35\x87\xCF' + data[4:-4]
+ checksum = hash256(test_data)[0:4]
+ return encode(test_data + checksum)
diff --git a/hwilib/_bech32.py b/hwilib/_bech32.py
new file mode 100644
index 0000000..815c666
--- /dev/null
+++ b/hwilib/_bech32.py
@@ -0,0 +1,157 @@
+# Copyright (c) 2017 Pieter Wuille
+#
+# 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.
+
+"""
+Bech32 Conversion Utilities
+***************************
+
+Reference implementation for Bech32 and segwit addresses.
+"""
+
+from enum import Enum
+from typing import (
+ List,
+ Optional,
+ Tuple,
+ Union,
+)
+
+
+CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
+BECH32_CONST = 1
+BECH32M_CONST = 0x2bc830a3
+
+class Encoding(Enum):
+ """Enumeration type to list the various supported encodings."""
+ BECH32 = 1
+ BECH32M = 2
+
+
+def bech32_polymod(values: List[int]) -> int:
+ """Internal function that computes the Bech32 checksum."""
+ generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
+ chk = 1
+ for value in values:
+ top = chk >> 25
+ chk = (chk & 0x1ffffff) << 5 ^ value
+ for i in range(5):
+ chk ^= generator[i] if ((top >> i) & 1) else 0
+ return chk
+
+
+def bech32_hrp_expand(hrp: str) -> List[int]:
+ """Expand the HRP into values for checksum computation."""
+ return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp]
+
+
+def bech32_verify_checksum(hrp: str, data: List[int]) -> Optional[Encoding]:
+ """Verify a checksum given HRP and converted data characters."""
+ check = bech32_polymod(bech32_hrp_expand(hrp) + data)
+ if check == BECH32_CONST:
+ return Encoding.BECH32
+ elif check == BECH32M_CONST:
+ return Encoding.BECH32M
+ else:
+ return None
+
+
+def bech32_create_checksum(encoding: Encoding, hrp: str, data: List[int]) -> List[int]:
+ """Compute the checksum values given HRP and data."""
+ values = bech32_hrp_expand(hrp) + data
+ const = BECH32M_CONST if encoding == Encoding.BECH32M else BECH32_CONST
+ polymod = bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ const
+ return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
+
+
+def bech32_encode(encoding: Encoding, hrp: str, data: List[int]) -> str:
+ """Compute a Bech32 string given HRP and data values."""
+ combined = data + bech32_create_checksum(encoding, hrp, data)
+ return hrp + '1' + ''.join([CHARSET[d] for d in combined])
+
+
+def bech32_decode(bech: str) -> Tuple[Optional[Encoding], Optional[str], Optional[List[int]]]:
+ """Validate a Bech32 string, and determine HRP and data."""
+ if ((any(ord(x) < 33 or ord(x) > 126 for x in bech)) or
+ (bech.lower() != bech and bech.upper() != bech)):
+ return (None, None, None)
+ bech = bech.lower()
+ pos = bech.rfind('1')
+ if pos < 1 or pos + 7 > len(bech) or len(bech) > 90:
+ return (None, None, None)
+ if not all(x in CHARSET for x in bech[pos + 1:]):
+ return (None, None, None)
+ hrp = bech[:pos]
+ data = [CHARSET.find(x) for x in bech[pos + 1:]]
+ encoding = bech32_verify_checksum(hrp, data)
+ if encoding is None:
+ return (None, None, None)
+ return (encoding, hrp, data[:-6])
+
+
+def convertbits(data: Union[bytes, List[int]], frombits: int, tobits: int, pad: bool = True) -> Optional[List[int]]:
+ """General power-of-2 base conversion."""
+ acc = 0
+ bits = 0
+ ret = []
+ maxv = (1 << tobits) - 1
+ max_acc = (1 << (frombits + tobits - 1)) - 1
+ for value in data:
+ if value < 0 or (value >> frombits):
+ return None
+ acc = ((acc << frombits) | value) & max_acc
+ bits += frombits
+ while bits >= tobits:
+ bits -= tobits
+ ret.append((acc >> bits) & maxv)
+ if pad:
+ if bits:
+ ret.append((acc << (tobits - bits)) & maxv)
+ elif bits >= frombits or ((acc << (tobits - bits)) & maxv):
+ return None
+ return ret
+
+
+def decode(hrp: str, addr: str) -> Tuple[Optional[int], Optional[List[int]]]:
+ """Decode a segwit address."""
+ encoding, hrpgot, data = bech32_decode(addr)
+ if hrpgot != hrp or hrpgot is None or data is None:
+ return (None, None)
+ decoded = convertbits(data[1:], 5, 8, False)
+ if decoded is None or len(decoded) < 2 or len(decoded) > 40:
+ return (None, None)
+ if data[0] > 16:
+ return (None, None)
+ if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32:
+ return (None, None)
+ if (data[0] == 0 and encoding != Encoding.BECH32) or (data[0] != 0 and encoding != Encoding.BECH32M):
+ return (None, None)
+ return (data[0], decoded)
+
+
+def encode(hrp: str, witver: int, witprog: bytes) -> Optional[str]:
+ """Encode a segwit address."""
+ encoding = Encoding.BECH32 if witver == 0 else Encoding.BECH32M
+ conv_bits = convertbits(witprog, 8, 5)
+ if conv_bits is None:
+ return None
+ ret = bech32_encode(encoding, hrp, [witver] + conv_bits)
+ if decode(hrp, ret) == (None, None):
+ return None
+ return ret
diff --git a/hwilib/_cli.py b/hwilib/_cli.py
new file mode 100644
index 0000000..e0afa7d
--- /dev/null
+++ b/hwilib/_cli.py
@@ -0,0 +1,306 @@
+#! /usr/bin/env python3
+
+from .commands import (
+ backup_device,
+ displayaddress,
+ enumerate,
+ find_device,
+ get_client,
+ getmasterxpub,
+ getxpub,
+ getkeypool,
+ getdescriptors,
+ prompt_pin,
+ toggle_passphrase,
+ restore_device,
+ send_pin,
+ setup_device,
+ signmessage,
+ signtx,
+ wipe_device,
+ install_udev_rules,
+)
+from .common import (
+ AddressType,
+ Chain,
+)
+from .errors import (
+ handle_errors,
+ DEVICE_CONN_ERROR,
+ HELP_TEXT,
+ MISSING_ARGUMENTS,
+ NO_DEVICE_TYPE,
+ UnavailableActionError,
+ UNKNOWN_ERROR,
+)
+from .hwwclient import HardwareWalletClient
+from . import __version__
+
+import argparse
+import getpass
+import logging
+import json
+import sys
+
+from typing import (
+ Any,
+ Dict,
+ IO,
+ List,
+ NoReturn,
+ Optional,
+ Union,
+)
+
+
+def backup_device_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, bool]:
+ return backup_device(client, label=args.label, backup_passphrase=args.backup_passphrase)
+
+def displayaddress_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, str]:
+ return displayaddress(client, desc=args.desc, path=args.path, addr_type=args.addr_type)
+
+def enumerate_handler(args: argparse.Namespace) -> List[Dict[str, Any]]:
+ return enumerate(password=args.password, expert=args.expert, chain=args.chain, allow_emulators=args.allow_emulators)
+
+def getmasterxpub_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, str]:
+ return getmasterxpub(client, addrtype=args.addr_type, account=args.account)
+
+def getxpub_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, str]:
+ return getxpub(client, path=args.path, expert=args.expert)
+
+def getkeypool_handler(args: argparse.Namespace, client: HardwareWalletClient) -> List[Dict[str, Any]]:
+ return getkeypool(client, path=args.path, start=args.start, end=args.end, internal=args.internal, keypool=args.keypool, account=args.account, addr_type=args.addr_type, addr_all=args.all)
+
+def getdescriptors_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, List[str]]:
+ return getdescriptors(client, account=args.account)
+
+def restore_device_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, bool]:
+ if args.interactive:
+ return restore_device(client, label=args.label, word_count=args.word_count)
+ raise UnavailableActionError("restore requires interactive mode")
+
+def setup_device_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, bool]:
+ if args.interactive:
+ return setup_device(client, label=args.label, backup_passphrase=args.backup_passphrase)
+ raise UnavailableActionError("setup requires interactive mode")
+
+def signmessage_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, str]:
+ return signmessage(client, message=args.message, path=args.path)
+
+def signtx_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, Union[bool, str]]:
+ return signtx(client, psbt=args.psbt)
+
+def wipe_device_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, bool]:
+ return wipe_device(client)
+
+def prompt_pin_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, bool]:
+ return prompt_pin(client)
+
+def toggle_passphrase_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, bool]:
+ return toggle_passphrase(client)
+
+def send_pin_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, bool]:
+ return send_pin(client, pin=args.pin)
+
+def install_udev_rules_handler(args: argparse.Namespace) -> Dict[str, bool]:
+ return install_udev_rules('udev', args.location)
+
+class HWIHelpFormatter(argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter):
+ pass
+
+class HWIArgumentParser(argparse.ArgumentParser):
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ super().__init__(*args, **kwargs)
+ self.formatter_class = HWIHelpFormatter
+
+ def print_usage(self, file: Optional[IO[str]] = None) -> None:
+ if file is None:
+ file = sys.stderr
+ super().print_usage(file)
+
+ def print_help(self, file: Optional[IO[str]] = None) -> None:
+ if file is None:
+ file = sys.stderr
+ super().print_help(file)
+ error = {'error': 'Help text requested', 'code': HELP_TEXT}
+ print(json.dumps(error))
+
+ def error(self, message: str) -> NoReturn:
+ self.print_usage(sys.stderr)
+ args = {'prog': self.prog, 'message': message}
+ error = {'error': '%(prog)s: error: %(message)s' % args, 'code': MISSING_ARGUMENTS}
+ print(json.dumps(error))
+ self.exit(2)
+
+def get_parser() -> HWIArgumentParser:
+ parser = HWIArgumentParser(description='Hardware Wallet Interface, version {}.\nAccess and send commands to a hardware wallet device. Responses are in JSON format.'.format(__version__))
+ parser.add_argument('--device-path', '-d', help='Specify the device path of the device to connect to')
+ parser.add_argument('--device-type', '-t', help='Specify the type of device that will be connected. If `--device-path` not given, the first device of this type enumerated is used.')
+ parser.add_argument('--password', '-p', help='Device password if it has one (e.g. DigitalBitbox)')
+ parser.add_argument('--stdinpass', help='Enter the device password on the command line', action='store_true')
+ parser.add_argument('--chain', help='Select chain to work with', type=Chain.argparse, choices=list(Chain), default=Chain.MAIN) # type: ignore
+ parser.add_argument('--debug', help='Print debug statements', action='store_true')
+ parser.add_argument('--fingerprint', '-f', help='Specify the device to connect to using the first 4 bytes of the hash160 of the master public key. It will connect to the first device that matches this fingerprint.')
+ parser.add_argument('--version', action='version', version='%(prog)s {}'.format(__version__))
+ parser.add_argument('--stdin', help='Enter commands and arguments via stdin', action='store_true')
+ parser.add_argument('--interactive', '-i', help='Use some commands interactively. Currently required for all device configuration commands', action='store_true')
+ parser.add_argument('--expert', help='Do advanced things and get more detailed information returned from some commands. Use at your own risk.', action='store_true')
+ parser.add_argument("--emulators", help="Enable enumeration and detection of device emulators", action="store_true", dest="allow_emulators")
+
+ subparsers = parser.add_subparsers(description='Commands', dest='command')
+ # work-around to make subparser required
+ subparsers.required = True
+
+ enumerate_parser = subparsers.add_parser('enumerate', help='List all available devices')
+ enumerate_parser.set_defaults(func=enumerate_handler)
+
+ getmasterxpub_parser = subparsers.add_parser('getmasterxpub', help='Get the extended public key for BIP 44 standard derivation paths. Convenience function to get xpubs given the address type, account, and chain type.')
+ getmasterxpub_parser.add_argument("--addr-type", help="Get the master xpub used to derive addresses for this address type", type=AddressType.argparse, choices=list(AddressType), default=AddressType.WIT) # type: ignore
+ getmasterxpub_parser.add_argument("--account", help="The account number", type=int, default=0)
+ getmasterxpub_parser.set_defaults(func=getmasterxpub_handler)
+
+ signtx_parser = subparsers.add_parser('signtx', help='Sign a PSBT')
+ signtx_parser.add_argument('psbt', help='The Partially Signed Bitcoin Transaction to sign')
+ signtx_parser.set_defaults(func=signtx_handler)
+
+ getxpub_parser = subparsers.add_parser('getxpub', help='Get an extended public key')
+ getxpub_parser.add_argument('path', help='The BIP 32 derivation path to derive the key at')
+ getxpub_parser.set_defaults(func=getxpub_handler)
+
+ signmsg_parser = subparsers.add_parser('signmessage', help='Sign a message')
+ signmsg_parser.add_argument('message', help='The message to sign')
+ signmsg_parser.add_argument('path', help='The BIP 32 derivation path of the key to sign the message with')
+ signmsg_parser.set_defaults(func=signmessage_handler)
+
+ getkeypool_parser = subparsers.add_parser('getkeypool', help='Get JSON array of keys that can be imported to Bitcoin Core with importmulti')
+ kparg_group = getkeypool_parser.add_mutually_exclusive_group()
+ kparg_group.add_argument('--keypool', action='store_true', dest='keypool', help='Indicates that the keys are to be imported to the keypool', default=True)
+ kparg_group.add_argument('--nokeypool', action='store_false', dest='keypool', help='Indicates that the keys are not to be imported to the keypool', default=False)
+ getkeypool_parser.add_argument('--internal', action='store_true', help='Indicates that the keys are change keys')
+ kp_type_group = getkeypool_parser.add_mutually_exclusive_group()
+ kp_type_group.add_argument("--addr-type", help="The address type (and default derivation path) to produce descriptors for", type=AddressType.argparse, choices=list(AddressType), default=AddressType.WIT) # type: ignore
+ kp_type_group.add_argument('--all', action='store_true', help='Generate addresses for all standard address types (default paths: ``m/{44,49,84}h/0h/0h/[0,1]/*)``')
+ getkeypool_parser.add_argument('--account', help='BIP43 account', type=int, default=0)
+ getkeypool_parser.add_argument('--path', help='Derivation path, default follows BIP43 convention, e.g. ``m/84h/0h/0h/1/*`` with --addr-type wpkh --internal. If this argument and --internal is not given, both internal and external keypools will be returned.')
+ getkeypool_parser.add_argument('start', type=int, help='The index to start at.')
+ getkeypool_parser.add_argument('end', type=int, help='The index to end at.')
+ getkeypool_parser.set_defaults(func=getkeypool_handler)
+
+ getdescriptors_parser = subparsers.add_parser('getdescriptors', help='Return receive and change descriptors for each supported address type, for import into a wallet.')
+ getdescriptors_parser.add_argument('--account', help='BIP43 account', type=int, default=0)
+ getdescriptors_parser.set_defaults(func=getdescriptors_handler)
+
+ displayaddr_parser = subparsers.add_parser('displayaddress', help='Display an address')
+ group = displayaddr_parser.add_mutually_exclusive_group(required=True)
+ group.add_argument('--desc', help='Output Descriptor. E.g. wpkh([00000000/84h/0h/0h]xpub.../0/0), where 00000000 must match --fingerprint and xpub can be obtained with getxpub. See doc/descriptors.md in Bitcoin Core')
+ group.add_argument('--path', help='The BIP 32 derivation path of the key embedded in the address, default follows BIP43 convention, e.g. ``m/84h/0h/0h/1/*``')
+ displayaddr_parser.add_argument("--addr-type", help="The address type to display", type=AddressType.argparse, choices=list(AddressType), default=AddressType.WIT) # type: ignore
+ displayaddr_parser.set_defaults(func=displayaddress_handler)
+
+ setupdev_parser = subparsers.add_parser('setup', help='Setup a device. Passphrase protection uses the password given by -p. Requires interactive mode')
+ setupdev_parser.add_argument('--label', '-l', help='The name to give to the device', default='')
+ setupdev_parser.add_argument('--backup_passphrase', '-b', help='The passphrase to use for the backup, if applicable', default='')
+ setupdev_parser.set_defaults(func=setup_device_handler)
+
+ wipedev_parser = subparsers.add_parser('wipe', help='Wipe a device')
+ wipedev_parser.set_defaults(func=wipe_device_handler)
+
+ restore_parser = subparsers.add_parser('restore', help='Initiate the device restoring process. Requires interactive mode')
+ restore_parser.add_argument('--word_count', '-w', help='Word count of your BIP39 recovery phrase (options: 12/18/24)', type=int, default=24)
+ restore_parser.add_argument('--label', '-l', help='The name to give to the device', default='')
+ restore_parser.set_defaults(func=restore_device_handler)
+
+ backup_parser = subparsers.add_parser('backup', help='Initiate the device backup creation process')
+ backup_parser.add_argument('--label', '-l', help='The name to give to the device', default='')
+ backup_parser.add_argument('--backup_passphrase', '-b', help='The passphrase to use for the backup, if applicable', default='')
+ backup_parser.set_defaults(func=backup_device_handler)
+
+ promptpin_parser = subparsers.add_parser('promptpin', help='Have the device prompt for your PIN')
+ promptpin_parser.set_defaults(func=prompt_pin_handler)
+
+ togglepassphrase_parser = subparsers.add_parser('togglepassphrase', help='Toggle BIP39 passphrase protection')
+ togglepassphrase_parser.set_defaults(func=toggle_passphrase_handler)
+
+ sendpin_parser = subparsers.add_parser('sendpin', help='Send the numeric positions for your PIN to the device')
+ sendpin_parser.add_argument('pin', help='The numeric positions of the PIN')
+ sendpin_parser.set_defaults(func=send_pin_handler)
+
+ if sys.platform.startswith("linux"):
+ udevrules_parser = subparsers.add_parser('installudevrules', help='Install and load the udev rule files for the hardware wallet devices')
+ udevrules_parser.add_argument('--location', help='The path where the udev rules files will be copied', default='/etc/udev/rules.d/')
+ udevrules_parser.set_defaults(func=install_udev_rules_handler)
+
+ return parser
+
+def process_commands(cli_args: List[str]) -> Any:
+ parser = get_parser()
+
+ if any(arg == '--stdin' for arg in cli_args):
+ while True:
+ try:
+ line = input()
+ # Exit loop when we see 2 consecutive newlines (i.e. an empty line)
+ if line == '':
+ break
+ # Split the line and append it to the cli args
+ import shlex
+ cli_args.extend(shlex.split(line))
+ except EOFError:
+ # If we see EOF, stop taking input
+ break
+
+ # Parse arguments again for anything entered over stdin
+ args = parser.parse_args(cli_args)
+
+ device_path = args.device_path
+ device_type = args.device_type
+ password = args.password
+ command = args.command
+ result: Dict[str, Any] = {}
+
+ # Setup debug logging
+ logging.basicConfig(level=logging.DEBUG if args.debug else logging.WARNING)
+
+ # Enter the password on stdin
+ if args.stdinpass:
+ password = getpass.getpass('Enter your device password: ')
+ args.password = password
+
+ # List all available hardware wallet devices
+ if command == 'enumerate':
+ return args.func(args)
+
+ # Install the devices udev rules for Linux
+ if command == 'installudevrules':
+ with handle_errors(msg="installudevrules failed:", result=result):
+ result = args.func(args)
+ return result
+
+ # Auto detect if we are using fingerprint or type to identify device
+ if args.fingerprint or (args.device_type and not args.device_path):
+ client = find_device(args.password, args.device_type, args.fingerprint, args.expert, args.chain, args.allow_emulators)
+ if not client:
+ return {'error': 'Could not find device with specified fingerprint or type', 'code': DEVICE_CONN_ERROR}
+ elif args.device_type and args.device_path:
+ with handle_errors(result=result, code=DEVICE_CONN_ERROR):
+ client = get_client(device_type, device_path, password, args.expert, args.chain)
+ if 'error' in result:
+ return result
+ else:
+ return {'error': 'You must specify a device type or fingerprint for all commands except enumerate', 'code': NO_DEVICE_TYPE}
+
+ if client is None:
+ return {"error": "Unable to communicated with device", "code": UNKNOWN_ERROR}
+
+ # Do the commands
+ with handle_errors(result=result, debug=args.debug):
+ result = args.func(args, client)
+
+ with handle_errors(result=result, debug=args.debug):
+ client.close()
+
+ return result
+
+def main() -> None:
+ result = process_commands(sys.argv[1:])
+ print(json.dumps(result))
diff --git a/hwilib/_gui.py b/hwilib/_gui.py
new file mode 100644
index 0000000..389a29e
--- /dev/null
+++ b/hwilib/_gui.py
@@ -0,0 +1,553 @@
+#! /usr/bin/env python3
+
+import base64
+import json
+import logging
+import sys
+import time
+from typing import Callable
+
+from . import commands, __version__
+from ._cli import HWIArgumentParser
+from .errors import handle_errors, DEVICE_NOT_INITIALIZED
+from .common import AddressType, Chain
+
+try:
+ from .ui.ui_bitbox02pairing import Ui_BitBox02PairingDialog
+ from .ui.ui_displayaddressdialog import Ui_DisplayAddressDialog
+ from .ui.ui_getxpubdialog import Ui_GetXpubDialog
+ from .ui.ui_getkeypooloptionsdialog import Ui_GetKeypoolOptionsDialog
+ from .ui.ui_mainwindow import Ui_MainWindow
+ from .ui.ui_sendpindialog import Ui_SendPinDialog
+ from .ui.ui_setpassphrasedialog import Ui_SetPassphraseDialog
+ from .ui.ui_signmessagedialog import Ui_SignMessageDialog
+ from .ui.ui_signpsbtdialog import Ui_SignPSBTDialog
+except ImportError:
+ print('Could not import UI files, did you run contrib/generate-ui.sh')
+ exit(-1)
+
+from PySide2.QtGui import QRegExpValidator
+from PySide2.QtWidgets import QApplication, QDialog, QDialogButtonBox, QFileDialog, QLineEdit, QMessageBox, QMainWindow, QMenu
+from PySide2.QtCore import QCoreApplication, QRegExp, Signal, Slot
+
+def do_command(f, *args, **kwargs):
+ result = {}
+ with handle_errors(result=result):
+ result = f(*args, **kwargs)
+ if 'error' in result:
+ msg = 'Error: {}\nCode:{}'.format(result['error'], result['code'])
+ QMessageBox.critical(None, "An Error Occurred", msg)
+ return None
+ return result
+
+class SetPassphraseDialog(QDialog):
+ def __init__(self):
+ super(SetPassphraseDialog, self).__init__()
+ self.ui = Ui_SetPassphraseDialog()
+ self.ui.setupUi(self)
+ self.setWindowTitle('Set Passphrase')
+
+ self.ui.passphrase_lineedit.setFocus()
+
+class SendPinDialog(QDialog):
+ pin_sent_success = Signal()
+
+ def __init__(self, client, prompt_pin=True):
+ super(SendPinDialog, self).__init__()
+ self.ui = Ui_SendPinDialog()
+ self.ui.setupUi(self)
+ self.setWindowTitle('Send Pin')
+ self.client = client
+ self.ui.pin_lineedit.setFocus()
+ self.ui.pin_lineedit.setValidator(QRegExpValidator(QRegExp("[1-9]+"), None))
+ self.ui.pin_lineedit.setEchoMode(QLineEdit.Password)
+
+ self.ui.p1_button.clicked.connect(self.button_clicked(1))
+ self.ui.p2_button.clicked.connect(self.button_clicked(2))
+ self.ui.p3_button.clicked.connect(self.button_clicked(3))
+ self.ui.p4_button.clicked.connect(self.button_clicked(4))
+ self.ui.p5_button.clicked.connect(self.button_clicked(5))
+ self.ui.p6_button.clicked.connect(self.button_clicked(6))
+ self.ui.p7_button.clicked.connect(self.button_clicked(7))
+ self.ui.p8_button.clicked.connect(self.button_clicked(8))
+ self.ui.p9_button.clicked.connect(self.button_clicked(9))
+
+ self.accepted.connect(self.sendpindialog_accepted)
+ if prompt_pin:
+ do_command(commands.prompt_pin, self.client)
+
+ def button_clicked(self, number):
+ @Slot()
+ def button_clicked_num():
+ self.ui.pin_lineedit.setText(self.ui.pin_lineedit.text() + str(number))
+ return button_clicked_num
+
+ @Slot()
+ def sendpindialog_accepted(self):
+ pin = self.ui.pin_lineedit.text()
+
+ # Send the pin
+ do_command(commands.send_pin, self.client, pin)
+ self.client.close()
+ self.client = None
+ self.pin_sent_success.emit()
+
+class GetXpubDialog(QDialog):
+ def __init__(self, client):
+ super(GetXpubDialog, self).__init__()
+ self.ui = Ui_GetXpubDialog()
+ self.ui.setupUi(self)
+ self.setWindowTitle('Get xpub')
+ self.client = client
+
+ self.ui.path_lineedit.setValidator(QRegExpValidator(QRegExp("m(/[0-9]+['Hh]?)+"), None))
+ self.ui.path_lineedit.setFocus()
+ self.ui.buttonBox.button(QDialogButtonBox.Close).setAutoDefault(False)
+
+ self.ui.getxpub_button.clicked.connect(self.getxpub_button_clicked)
+ self.ui.buttonBox.clicked.connect(self.accept)
+
+ @Slot()
+ def getxpub_button_clicked(self):
+ path = self.ui.path_lineedit.text()
+ res = do_command(commands.getxpub, self.client, path)
+ self.ui.xpub_textedit.setText(res['xpub'])
+
+class SignPSBTDialog(QDialog):
+ def __init__(self, client):
+ super(SignPSBTDialog, self).__init__()
+ self.ui = Ui_SignPSBTDialog()
+ self.ui.setupUi(self)
+ self.setWindowTitle('Sign PSBT')
+ self.client = client
+
+ self.ui.psbt_in_textedit.setFocus()
+
+ self.ui.sign_psbt_button.clicked.connect(self.sign_psbt_button_clicked)
+ self.ui.buttonBox.clicked.connect(self.accept)
+
+ menu = QMenu()
+ self.ui.import_toolbutton.setMenu(menu)
+ menu = self.ui.import_toolbutton.menu()
+ menu.addAction("From binary").triggered.connect(self.import_binary_clicked)
+ menu.addAction("From base64").triggered.connect(self.import_base64_clicked)
+
+ menu = QMenu()
+ self.ui.export_toolbutton.setMenu(menu)
+ menu = self.ui.export_toolbutton.menu()
+ menu.addAction("To binary").triggered.connect(self.export_binary_clicked)
+ menu.addAction("To base64").triggered.connect(self.export_base64_clicked)
+
+ @Slot()
+ def import_base64_clicked(self):
+ filename, _ = QFileDialog.getOpenFileName(self, 'Open file')
+ if filename:
+ with open(filename, 'r', encoding='utf-8') as f:
+ b64 = f.read()
+ self.ui.psbt_in_textedit.setPlainText(b64)
+
+ @Slot()
+ def import_binary_clicked(self):
+ filename, _ = QFileDialog.getOpenFileName(self, 'Open file', "", "PSBT (*.psbt)")
+ if filename:
+ with open(filename, 'rb') as f:
+ bin = f.read()
+ b64 = base64.b64encode(bin).decode()
+ self.ui.psbt_in_textedit.setPlainText(b64)
+
+ @Slot()
+ def export_base64_clicked(self):
+ filename, _ = QFileDialog.getSaveFileName(self, 'Save file')
+ if filename:
+ with open(filename, 'w') as f:
+ b64 = self.ui.psbt_out_textedit.toPlainText()
+ f.write(b64)
+
+ @Slot()
+ def export_binary_clicked(self):
+ filename, _ = QFileDialog.getSaveFileName(self, 'Save file', "untitled.psbt")
+ if filename:
+ with open(filename, 'wb') as f:
+ b64 = self.ui.psbt_out_textedit.toPlainText()
+ bin = base64.b64decode(b64.encode())
+ f.write(bin)
+
+ @Slot()
+ def sign_psbt_button_clicked(self):
+ psbt_str = self.ui.psbt_in_textedit.toPlainText()
+ res = do_command(commands.signtx, self.client, psbt_str)
+ self.ui.psbt_out_textedit.setPlainText(res['psbt'])
+
+class SignMessageDialog(QDialog):
+ def __init__(self, client):
+ super(SignMessageDialog, self).__init__()
+ self.ui = Ui_SignMessageDialog()
+ self.ui.setupUi(self)
+ self.setWindowTitle('Sign Message')
+ self.client = client
+
+ self.ui.path_lineedit.setValidator(QRegExpValidator(QRegExp("m(/[0-9]+['Hh]?)+"), None))
+ self.ui.msg_textedit.setFocus()
+
+ self.ui.signmsg_button.clicked.connect(self.signmsg_button_clicked)
+ self.ui.buttonBox.clicked.connect(self.accept)
+
+ @Slot()
+ def signmsg_button_clicked(self):
+ msg_str = self.ui.msg_textedit.toPlainText()
+ path = self.ui.path_lineedit.text()
+ res = do_command(commands.signmessage, self.client, msg_str, path)
+ self.ui.sig_textedit.setPlainText(res['signature'])
+
+class DisplayAddressDialog(QDialog):
+ def __init__(self, client):
+ super(DisplayAddressDialog, self).__init__()
+ self.ui = Ui_DisplayAddressDialog()
+ self.ui.setupUi(self)
+ self.setWindowTitle('Display Address')
+ self.client = client
+
+ self.ui.path_lineedit.setValidator(QRegExpValidator(QRegExp("m(/[0-9]+['Hh]?)+"), None))
+ self.ui.path_lineedit.setFocus()
+
+ self.ui.go_button.clicked.connect(self.go_button_clicked)
+ self.ui.buttonBox.clicked.connect(self.accept)
+
+ @Slot()
+ def go_button_clicked(self):
+ path = self.ui.path_lineedit.text()
+ if self.ui.sh_wpkh_radio.isChecked():
+ addrtype = AddressType.SH_WIT
+ elif self.ui.wpkh_radio.isChecked():
+ addrtype = AddressType.WIT
+ elif self.ui.pkh_radio.isChecked():
+ addrtype = AddressType.LEGACY
+ else:
+ assert False # How did this happen?
+ res = do_command(commands.displayaddress, self.client, path, addr_type=addrtype)
+ self.ui.address_lineedit.setText(res['address'])
+
+class GetKeypoolOptionsDialog(QDialog):
+ def __init__(self, opts):
+ super(GetKeypoolOptionsDialog, self).__init__()
+ self.ui = Ui_GetKeypoolOptionsDialog()
+ self.ui.setupUi(self)
+ self.setWindowTitle('Set getkeypool options')
+
+ self.ui.start_spinbox.setValue(opts['start'])
+ self.ui.end_spinbox.setValue(opts['end'])
+ self.ui.internal_checkbox.setChecked(opts['internal'])
+ self.ui.keypool_checkbox.setChecked(opts['keypool'])
+ self.ui.account_spinbox.setValue(opts['account'])
+ self.ui.path_lineedit.setValidator(QRegExpValidator(QRegExp(r"m(/[0-9]+['Hh]?)+/\*"), None))
+ if opts['account_used']:
+ self.ui.account_radio.setChecked(True)
+ self.ui.path_radio.setChecked(False)
+ self.ui.path_lineedit.setEnabled(False)
+ self.ui.account_spinbox.setEnabled(True)
+ self.ui.account_spinbox.setValue(opts['account'])
+ else:
+ self.ui.account_radio.setChecked(False)
+ self.ui.path_radio.setChecked(True)
+ self.ui.path_lineedit.setEnabled(True)
+ self.ui.account_spinbox.setEnabled(False)
+ self.ui.path_lineedit.setText(opts['path'])
+ self.ui.sh_wpkh_radio.setChecked(opts['addrtype'] == AddressType.SH_WIT)
+ self.ui.wpkh_radio.setChecked(opts['addrtype'] == AddressType.WIT)
+ self.ui.pkh_radio.setChecked(opts['addrtype'] == AddressType.LEGACY)
+
+ self.ui.account_radio.toggled.connect(self.toggle_account)
+
+ @Slot()
+ def toggle_account(self, checked):
+ if checked:
+ self.ui.path_lineedit.setEnabled(False)
+ self.ui.account_spinbox.setEnabled(True)
+ else:
+ self.ui.path_lineedit.setEnabled(True)
+ self.ui.account_spinbox.setEnabled(False)
+
+try:
+ # Try to import bitbox02 things
+ # Not all dependencies may be available, in which case just ignore these two classes
+ # The code that needs this should already be (implicitly) guarded by bitbox02_lib imports working
+ # so these classes will not be referenced in that case.
+ from .devices.bitbox02_lib.util import BitBoxAppNoiseConfig
+
+ class BitBox02PairingDialog(QDialog):
+ def __init__(self, pairing_code: str, device_response: Callable[[], bool]):
+ super(BitBox02PairingDialog, self).__init__()
+ self.ui = Ui_BitBox02PairingDialog()
+ self.ui.setupUi(self)
+ self.setWindowTitle('Verify BitBox02 pairing code')
+ self.ui.pairingCode.setText(pairing_code.replace("\n", "<br>"))
+ self.ui.buttonBox.setEnabled(False)
+ self.device_response = device_response
+ self.painted = False
+
+ def paintEvent(self, ev):
+ super().paintEvent(ev)
+ self.painted = True
+
+ def enable_buttons(self):
+ self.ui.buttonBox.setEnabled(True)
+
+ class BitBox02NoiseConfig(BitBoxAppNoiseConfig):
+ """ GUI elements to perform the BitBox02 pairing and attestatoin check """
+
+ def show_pairing(self, code: str, device_response: Callable[[], bool]) -> bool:
+ dialog = BitBox02PairingDialog(code, device_response)
+ dialog.show()
+ # render the window since the next operation is blocking
+ while True:
+ QCoreApplication.processEvents()
+ if dialog.painted:
+ break
+ time.sleep(0.1)
+ if not device_response():
+ return False
+ dialog.enable_buttons()
+ dialog.exec_()
+ return dialog.result() == QDialog.Accepted
+
+ def attestation_check(self, result: bool) -> None:
+ if not result:
+ QMessageBox.warning(
+ None,
+ "BitBox02 attestation check",
+ "BitBox02 attestation check failed. Your BitBox02 might not be genuine. Please contact support@shiftcrypto.ch if the problem persists.",
+ )
+except ImportError:
+ pass
+
+class HWIQt(QMainWindow):
+ def __init__(self, passphrase=None, chain=Chain.MAIN, allow_emulators: bool = False):
+ super(HWIQt, self).__init__()
+ self.ui = Ui_MainWindow()
+ self.ui.setupUi(self)
+ self.setWindowTitle(f'HWI Qt - {chain}')
+
+ self.devices = []
+ self.client = None
+ self.device_info = {}
+ self.passphrase = passphrase
+ self.chain = chain
+ self.current_dialog = None
+ self.getkeypool_opts = {
+ 'start': 0,
+ 'end': 1000,
+ 'account': 0,
+ 'internal': False,
+ 'keypool': True,
+ 'addrtype': AddressType.SH_WIT,
+ 'path': None,
+ 'account_used': True
+ }
+ self.allow_emulators = allow_emulators
+
+ self.ui.enumerate_refresh_button.clicked.connect(self.refresh_clicked)
+ self.ui.setpass_button.clicked.connect(self.show_setpassphrasedialog)
+ self.ui.sendpin_button.clicked.connect(lambda: self.show_sendpindialog(prompt_pin=True))
+ self.ui.getxpub_button.clicked.connect(self.show_getxpubdialog)
+ self.ui.signtx_button.clicked.connect(self.show_signpsbtdialog)
+ self.ui.signmsg_button.clicked.connect(self.show_signmessagedialog)
+ self.ui.display_addr_button.clicked.connect(self.show_displayaddressdialog)
+ self.ui.getkeypool_opts_button.clicked.connect(self.show_getkeypooloptionsdialog)
+ self.ui.toggle_passphrase_button.clicked.connect(self.toggle_passphrase)
+
+ self.ui.enumerate_combobox.currentIndexChanged.connect(self.get_client_and_device_info)
+
+ def clear_info(self):
+ self.ui.getxpub_button.setEnabled(False)
+ self.ui.signtx_button.setEnabled(False)
+ self.ui.signmsg_button.setEnabled(False)
+ self.ui.display_addr_button.setEnabled(False)
+ self.ui.getkeypool_opts_button.setEnabled(False)
+ self.ui.toggle_passphrase_button.setEnabled(False)
+ self.ui.keypool_textedit.clear()
+ self.ui.desc_textedit.clear()
+
+ @Slot()
+ def refresh_clicked(self):
+ if self.client:
+ self.client.close()
+ self.client = None
+
+ self.devices = commands.enumerate(password=self.passphrase, expert=False, chain=self.chain, allow_emulators=self.allow_emulators)
+ self.ui.enumerate_combobox.currentIndexChanged.disconnect()
+ self.ui.enumerate_combobox.clear()
+ self.ui.enumerate_combobox.addItem('')
+ for dev in self.devices:
+ fingerprint = 'none'
+ if 'fingerprint' in dev:
+ fingerprint = dev['fingerprint']
+ dev_str = '{} fingerprint:{} path:{}'.format(dev['model'], fingerprint, dev['path'])
+ self.ui.enumerate_combobox.addItem(dev_str)
+ self.ui.enumerate_combobox.currentIndexChanged.connect(self.get_client_and_device_info)
+ self.clear_info()
+
+ @Slot()
+ def show_setpassphrasedialog(self):
+ self.current_dialog = SetPassphraseDialog()
+ self.current_dialog.accepted.connect(self.setpassphrasedialog_accepted)
+ self.current_dialog.exec_()
+
+ @Slot()
+ def setpassphrasedialog_accepted(self):
+ self.passphrase = self.current_dialog.ui.passphrase_lineedit.text()
+ self.current_dialog = None
+
+ @Slot()
+ def get_client_and_device_info(self, index):
+ self.ui.sendpin_button.setEnabled(False)
+ if index == 0:
+ self.clear_info()
+ return
+
+ self.ui.getxpub_button.setEnabled(True)
+ self.ui.signtx_button.setEnabled(True)
+ self.ui.display_addr_button.setEnabled(True)
+ self.ui.getkeypool_opts_button.setEnabled(True)
+
+ # Get the client
+ self.device_info = self.devices[index - 1]
+ self.client = commands.get_client(self.device_info['model'], self.device_info['path'], self.passphrase, self.chain)
+
+ if self.device_info['type'] == 'bitbox02':
+ self.client.set_noise_config(BitBox02NoiseConfig())
+
+ self.ui.setpass_button.setEnabled(self.device_info['type'] != 'bitbox02')
+ self.ui.signmsg_button.setEnabled(True)
+ self.ui.toggle_passphrase_button.setEnabled(self.device_info['type'] in ('trezor', 'keepkey', 'bitbox02', ))
+
+ self.get_device_info()
+
+ def get_device_info(self):
+ # Enable the sendpin button if it's a trezor and it needs it
+ if self.device_info['needs_pin_sent']:
+ self.ui.sendpin_button.setEnabled(True)
+ self.clear_info()
+ return
+ else:
+ self.ui.sendpin_button.setEnabled(False)
+
+ # If it isn't initialized, show an error but don't do anything
+ if 'code' in self.device_info and self.device_info['code'] == DEVICE_NOT_INITIALIZED:
+ self.clear_info()
+ QMessageBox.information(None, "Not initialized yet", 'Device is not initialized yet')
+ return
+
+ # do getkeypool and getdescriptors
+ keypool = do_command(commands.getkeypool, self.client,
+ None if self.getkeypool_opts['account_used'] else self.getkeypool_opts['path'],
+ self.getkeypool_opts['start'],
+ self.getkeypool_opts['end'],
+ self.getkeypool_opts['internal'],
+ self.getkeypool_opts['keypool'],
+ self.getkeypool_opts['account'],
+ self.getkeypool_opts['addrtype'])
+ descriptors = do_command(commands.getdescriptors, self.client, self.getkeypool_opts['account'])
+
+ self.ui.keypool_textedit.setPlainText(json.dumps(keypool, indent=2))
+ self.ui.desc_textedit.setPlainText(json.dumps(descriptors, indent=2))
+
+ @Slot()
+ def show_sendpindialog(self, prompt_pin=True):
+ self.current_dialog = SendPinDialog(self.client, prompt_pin)
+ self.current_dialog.pin_sent_success.connect(self.sendpindialog_accepted)
+ self.current_dialog.exec_()
+
+ @Slot()
+ def sendpindialog_accepted(self):
+ self.current_dialog = None
+
+ curr_index = self.ui.enumerate_combobox.currentIndex()
+ self.refresh_clicked()
+ self.ui.enumerate_combobox.setCurrentIndex(curr_index)
+
+ @Slot()
+ def show_getxpubdialog(self):
+ self.current_dialog = GetXpubDialog(self.client)
+ self.current_dialog.exec_()
+
+ @Slot()
+ def show_signpsbtdialog(self):
+ self.current_dialog = SignPSBTDialog(self.client)
+ self.current_dialog.exec_()
+
+ @Slot()
+ def show_signmessagedialog(self):
+ self.current_dialog = SignMessageDialog(self.client)
+ self.current_dialog.exec_()
+
+ @Slot()
+ def show_displayaddressdialog(self):
+ self.current_dialog = DisplayAddressDialog(self.client)
+ self.current_dialog.exec_()
+
+ @Slot()
+ def show_getkeypooloptionsdialog(self):
+ self.current_dialog = GetKeypoolOptionsDialog(self.getkeypool_opts)
+ self.current_dialog.accepted.connect(self.getkeypooloptionsdialog_accepted)
+ self.current_dialog.exec_()
+
+ @Slot()
+ def getkeypooloptionsdialog_accepted(self):
+ self.getkeypool_opts['start'] = self.current_dialog.ui.start_spinbox.value()
+ self.getkeypool_opts['end'] = self.current_dialog.ui.end_spinbox.value()
+ self.getkeypool_opts['internal'] = self.current_dialog.ui.internal_checkbox.isChecked()
+ self.getkeypool_opts['keypool'] = self.current_dialog.ui.keypool_checkbox.isChecked()
+ self.getkeypool_opts['addrtype'] = AddressType.LEGACY
+ if self.current_dialog.ui.sh_wpkh_radio.isChecked():
+ self.getkeypool_opts['addrtype'] = AddressType.SH_WIT
+ if self.current_dialog.ui.wpkh_radio.isChecked():
+ self.getkeypool_opts['addrtype'] = AddressType.WIT
+ if self.current_dialog.ui.pkh_radio.isChecked():
+ self.getkeypool_opts['addrtype'] = AddressType.LEGACY
+ if self.current_dialog.ui.account_radio.isChecked():
+ self.getkeypool_opts['account'] = self.current_dialog.ui.account_spinbox.value()
+ self.getkeypool_opts['account_used'] = True
+ else:
+ self.getkeypool_opts['path'] = self.current_dialog.ui.path_lineedit.text()
+ self.getkeypool_opts['account_used'] = False
+ self.current_dialog = None
+ self.get_device_info()
+
+ @Slot()
+ def toggle_passphrase(self):
+ do_command(commands.toggle_passphrase, self.client)
+ if self.device_info['model'] == "keepkey":
+ self.show_sendpindialog(prompt_pin=False)
+
+def process_gui_commands(cli_args):
+ parser = HWIArgumentParser(description='Hardware Wallet Interface Qt, version {}.\nInteractively access and send commands to a hardware wallet device with a GUI. Responses are in JSON format.'.format(__version__))
+ parser.add_argument('--password', '-p', help='Device password if it has one (e.g. DigitalBitbox)', default=None)
+ parser.add_argument('--chain', help='Select chain to work with', type=Chain.argparse, choices=list(Chain), default=Chain.MAIN)
+ parser.add_argument('--debug', help='Print debug statements', action='store_true')
+ parser.add_argument('--version', action='version', version='%(prog)s {}'.format(__version__))
+ parser.add_argument("--emulators", help="Enable enumeration and detection of device emulators", action="store_true", dest="allow_emulators")
+
+ # Parse arguments again for anything entered over stdin
+ args = parser.parse_args(cli_args)
+
+ result = {}
+
+ # Setup debug logging
+ logging.basicConfig(level=logging.DEBUG if args.debug else logging.WARNING)
+
+ # Qt setup
+ app = QApplication()
+
+ window = HWIQt(args.password, args.chain, args.allow_emulators)
+
+ window.refresh_clicked()
+
+ window.show()
+ ret = app.exec_()
+ result = {'success': ret == 0}
+
+ return result
+
+def main():
+ result = process_gui_commands(sys.argv[1:])
+ print(json.dumps(result))
diff --git a/hwilib/_script.py b/hwilib/_script.py
new file mode 100644
index 0000000..95aef03
--- /dev/null
+++ b/hwilib/_script.py
@@ -0,0 +1,155 @@
+"""
+Bitcoin Script utilities
+************************
+"""
+
+from typing import (
+ Optional,
+ Sequence,
+ Tuple,
+)
+
+
+def is_opreturn(script: bytes) -> bool:
+ """
+ Determine whether a script is an OP_RETURN output script.
+
+ :param script: The script
+ :returns: Whether the script is an OP_RETURN output script
+ """
+ return script[0] == 0x6a
+
+
+def is_p2sh(script: bytes) -> bool:
+ """
+ Determine whether a script is a P2SH output script.
+
+ :param script: The script
+ :returns: Whether the script is a P2SH output script
+ """
+ return len(script) == 23 and script[0] == 0xa9 and script[1] == 0x14 and script[22] == 0x87
+
+
+def is_p2pkh(script: bytes) -> bool:
+ """
+ Determine whether a script is a P2PKH output script.
+
+ :param script: The script
+ :returns: Whether the script is a P2PKH output script
+ """
+ return len(script) == 25 and script[0] == 0x76 and script[1] == 0xa9 and script[2] == 0x14 and script[23] == 0x88 and script[24] == 0xac
+
+
+def is_p2pk(script: bytes) -> bool:
+ """
+ Determine whether a script is a P2PK output script.
+
+ :param script: The script
+ :returns: Whether the script is a P2PK output script
+ """
+ return (len(script) == 35 or len(script) == 67) and (script[0] == 0x21 or script[0] == 0x41) and script[-1] == 0xac
+
+
+def is_witness(script: bytes) -> Tuple[bool, int, bytes]:
+ """
+ Determine whether a script is a segwit output script.
+ If so, also returns the witness version and witness program.
+
+ :param script: The script
+ :returns: A tuple of a bool indicating whether the script is a segwit output script,
+ an int representing the witness version,
+ and the bytes of the witness program.
+ """
+ if len(script) < 4 or len(script) > 42:
+ return (False, 0, b"")
+
+ if script[0] != 0 and (script[0] < 81 or script[0] > 96):
+ return (False, 0, b"")
+
+ if script[1] + 2 == len(script):
+ return (True, script[0] - 0x50 if script[0] else 0, script[2:])
+
+ return (False, 0, b"")
+
+
+def is_p2wpkh(script: bytes) -> bool:
+ """
+ Determine whether a script is a P2WPKH output script.
+
+ :param script: The script
+ :returns: Whether the script is a P2WPKH output script
+ """
+ is_wit, wit_ver, wit_prog = is_witness(script)
+ if not is_wit:
+ return False
+ elif wit_ver != 0:
+ return False
+ return len(wit_prog) == 20
+
+
+def is_p2wsh(script: bytes) -> bool:
+ """
+ Determine whether a script is a P2WSH output script.
+
+ :param script: The script
+ :returns: Whether the script is a P2WSH output script
+ """
+ is_wit, wit_ver, wit_prog = is_witness(script)
+ if not is_wit:
+ return False
+ elif wit_ver != 0:
+ return False
+ return len(wit_prog) == 32
+
+def is_p2tr(script: bytes) -> bool:
+ """
+ Determine whether a script is a P2TR output script.
+
+ :param script: The script
+ :returns: Whether the script is a P2TR output script
+ """
+ is_wit, wit_ver, wit_prog = is_witness(script)
+ if not is_wit:
+ return False
+ elif wit_ver != 1:
+ return False
+ return len(wit_prog) == 32
+
+
+# Only handles up to 15 of 15. Returns None if this script is not a
+# multisig script. Returns (m, pubkeys) otherwise.
+def parse_multisig(script: bytes) -> Optional[Tuple[int, Sequence[bytes]]]:
+ """
+ Determine whether a script is a multisig script. If so, determine the parameters of that multisig.
+
+ :param script: The script
+ :returns: ``None`` if the script is not multisig.
+ If multisig, returns a tuple of the number of signers required,
+ and a sequence of public key bytes.
+ """
+ # Get m
+ m = script[0] - 80
+ if m < 1 or m > 15:
+ return None
+
+ # Get pubkeys
+ pubkeys = []
+ offset = 1
+ while True:
+ pubkey_len = script[offset]
+ if pubkey_len != 33:
+ break
+ offset += 1
+ pubkeys.append(script[offset:offset + 33])
+ offset += 33
+
+ # Check things at the end
+ n = script[offset] - 80
+ if n != len(pubkeys):
+ return None
+ offset += 1
+ op_cms = script[offset]
+ if op_cms != 174:
+ return None
+
+ return (m, pubkeys)
diff --git a/hwilib/_serialize.py b/hwilib/_serialize.py
new file mode 100644
index 0000000..7640e5d
--- /dev/null
+++ b/hwilib/_serialize.py
@@ -0,0 +1,258 @@
+#!/usr/bin/env python3
+# Copyright (c) 2010 ArtForz -- public domain half-a-node
+# Copyright (c) 2012 Jeff Garzik
+# Copyright (c) 2010-2016 The Bitcoin Core developers
+# Distributed under the MIT software license, see the accompanying
+# file COPYING or http://www.opensource.org/licenses/mit-license.php.
+"""
+Bitcoin Object Python Serializations
+************************************
+
+Modified from the test/test_framework/mininode.py file from the
+Bitcoin repository
+"""
+
+import struct
+
+from typing import (
+ List,
+ Sequence,
+ TypeVar,
+ Callable,
+)
+from typing_extensions import Protocol
+
+class Readable(Protocol):
+ def read(self, n: int = -1) -> bytes:
+ ...
+
+class Deserializable(Protocol):
+ def deserialize(self, f: Readable) -> None:
+ ...
+
+class Serializable(Protocol):
+ def serialize(self) -> bytes:
+ ...
+
+
+# Serialization/deserialization tools
+def ser_compact_size(size: int) -> bytes:
+ """
+ Serialize an integer using Bitcoin's compact size unsigned integer serialization.
+
+ :param size: The int to serialize
+ :returns: The int serialized as a compact size unsigned integer
+ """
+ r = b""
+ if size < 253:
+ r = struct.pack("B", size)
+ elif size < 0x10000:
+ r = struct.pack("<BH", 253, size)
+ elif size < 0x100000000:
+ r = struct.pack("<BI", 254, size)
+ else:
+ r = struct.pack("<BQ", 255, size)
+ return r
+
+def deser_compact_size(f: Readable) -> int:
+ """
+ Deserialize a compact size unsigned integer from the beginning of the byte stream.
+
+ :param f: The byte stream
+ :returns: The integer that was serialized
+ """
+ nit: int = struct.unpack("<B", f.read(1))[0]
+ if nit == 253:
+ nit = struct.unpack("<H", f.read(2))[0]
+ elif nit == 254:
+ nit = struct.unpack("<I", f.read(4))[0]
+ elif nit == 255:
+ nit = struct.unpack("<Q", f.read(8))[0]
+ return nit
+
+def deser_string(f: Readable) -> bytes:
+ """
+ Deserialize a variable length byte string serialized with Bitcoin's variable length string serialization from a byte stream.
+
+ :param f: The byte stream
+ :returns: The byte string that was serialized
+ """
+ nit = deser_compact_size(f)
+ return f.read(nit)
+
+def ser_string(s: bytes) -> bytes:
+ """
+ Serialize a byte string with Bitcoin's variable length string serialization.
+
+ :param s: The byte string to be serialized
+ :returns: The serialized byte string
+ """
+ return ser_compact_size(len(s)) + s
+
+def deser_uint256(f: Readable) -> int:
+ """
+ Deserialize a 256 bit integer serialized with Bitcoin's 256 bit integer serialization from a byte stream.
+
+ :param f: The byte stream.
+ :returns: The integer that was serialized
+ """
+ r = 0
+ for i in range(8):
+ t = struct.unpack("<I", f.read(4))[0]
+ r += t << (i * 32)
+ return r
+
+
+def ser_uint256(u: int) -> bytes:
+ """
+ Serialize a 256 bit integer with Bitcoin's 256 bit integer serialization.
+
+ :param u: The integer to serialize
+ :returns: The serialized 256 bit integer
+ """
+ rs = b""
+ for _ in range(8):
+ rs += struct.pack("<I", u & 0xFFFFFFFF)
+ u >>= 32
+ return rs
+
+
+def uint256_from_str(s: bytes) -> int:
+ """
+ Deserialize a 256 bit integer serialized with Bitcoin's 256 bit integer serialization from a byte string.
+
+ :param s: The byte string
+ :returns: The integer that was serialized
+ """
+ r = 0
+ t = struct.unpack("<IIIIIIII", s[:32])
+ for i in range(8):
+ r += t[i] << (i * 32)
+ return r
+
+D = TypeVar("D", bound=Deserializable)
+
+def deser_vector(f: Readable, c: Callable[[], D]) -> List[D]:
+ """
+ Deserialize a vector of objects with Bitcoin's object vector serialization from a byte stream.
+
+ :param f: The byte stream
+ :param c: The class of object to deserialize for each object in the vector
+ :returns: A list of objects that were serialized
+ """
+ nit = deser_compact_size(f)
+ r = []
+ for _ in range(nit):
+ t = c()
+ t.deserialize(f)
+ r.append(t)
+ return r
+
+
+def ser_vector(v: Sequence[Serializable]) -> bytes:
+ """
+ Serialize a vector of objects with Bitcoin's object vector serialzation.
+
+ :param v: The list of objects to serialize
+ :returns: The serialized objects
+ """
+ r = ser_compact_size(len(v))
+ for i in v:
+ r += i.serialize()
+ return r
+
+
+def deser_string_vector(f: Readable) -> List[bytes]:
+ """
+ Deserialize a vector of byte strings from a byte stream.
+
+ :param f: The byte stream
+ :returns: The list of byte strings that were serialized
+ """
+ nit = deser_compact_size(f)
+ r = []
+ for _ in range(nit):
+ t = deser_string(f)
+ r.append(t)
+ return r
+
+
+def ser_string_vector(v: List[bytes]) -> bytes:
+ """
+ Serialize a list of byte strings as a vector of byte strings.
+
+ :param v: The list of byte strings to serialize
+ :returns: The serialized list of byte strings
+ """
+ r = ser_compact_size(len(v))
+ for sv in v:
+ r += ser_string(sv)
+ return r
+
+def ser_sig_der(r: bytes, s: bytes) -> bytes:
+ """
+ Serialize the ``r`` and ``s`` values of an ECDSA signature using DER.
+
+ :param r: The ``r`` value bytes
+ :param s: The ``s`` value bytes
+ :returns: The DER encoded signature
+ """
+ sig = b"\x30"
+
+ # Make r and s as short as possible
+ ri = 0
+ for b in r:
+ if b == 0:
+ ri += 1
+ else:
+ break
+ r = r[ri:]
+ si = 0
+ for b in s:
+ if b == 0:
+ si += 1
+ else:
+ break
+ s = s[si:]
+
+ # Make positive of neg
+ first = r[0]
+ if first & (1 << 7) != 0:
+ r = b"\x00" + r
+ first = s[0]
+ if first & (1 << 7) != 0:
+ s = b"\x00" + s
+
+ # Write total length
+ total_len = len(r) + len(s) + 4
+ sig += struct.pack("B", total_len)
+
+ # write r
+ sig += b"\x02"
+ sig += struct.pack("B", len(r))
+ sig += r
+
+ # write s
+ sig += b"\x02"
+ sig += struct.pack("B", len(s))
+ sig += s
+
+ sig += b"\x01"
+ return sig
+
+def ser_sig_compact(r: bytes, s: bytes, recid: bytes) -> bytes:
+ """
+ Serialize the ``r`` and ``s`` values of an ECDSA signature using the compact signature serialization scheme.
+
+ :param r: The ``r`` value bytes
+ :param s: The ``s`` value bytes
+ :returns: The compact signature
+ """
+ rec = struct.unpack("B", recid)[0]
+ prefix = struct.pack("B", 27 + 4 + rec)
+
+ sig = b""
+ sig += prefix
+ sig += r + s
+
+ return sig
diff --git a/hwilib/commands.py b/hwilib/commands.py
new file mode 100644
index 0000000..6d192aa
--- /dev/null
+++ b/hwilib/commands.py
@@ -0,0 +1,592 @@
+#! /usr/bin/env python3
+
+"""
+Commands
+********
+
+The functions in this module are the primary way to interact with hardware wallets.
+Each function that takes a ``client`` uses a :class:`~hwilib.hwwclient.HardwareWalletClient`.
+The functions then call public members of that client to retrieve the data needed.
+
+Clients can be constructed using :func:`~find_device` or :func:`~get_client`.
+
+The :func:`~enumerate` function returns information about what devices are available to be connected to.
+These information can then be used with :func:`~find_device` or :func:`~get_client` to get a :class:`~hwilib.hwwclient.HardwareWalletClient`.
+
+Note that this documentation does not specify every exception that can be raised.
+Many exceptions are buried within the functions implemented by each device's :class:`~hwilib.hwwclient.HardwareWalletClient`.
+For more information about the exceptions that those can raise, please see the specific client documentation.
+"""
+
+import importlib
+import logging
+import platform
+
+from ._base58 import xpub_to_pub_hex, xpub_to_xonly_pub_hex
+from .key import (
+ get_bip44_purpose,
+ get_bip44_chain,
+ H_,
+ HARDENED_FLAG,
+ is_hardened,
+ KeyOriginInfo,
+ parse_path,
+)
+from .errors import (
+ BadArgumentError,
+ NotImplementedError,
+ UnknownDeviceError,
+ UnavailableActionError,
+)
+from .descriptor import (
+ Descriptor,
+ parse_descriptor,
+ MultisigDescriptor,
+ TRDescriptor,
+ PKHDescriptor,
+ PubkeyProvider,
+ SHDescriptor,
+ WPKHDescriptor,
+ WSHDescriptor,
+)
+from .devices import __all__ as all_devs
+from .common import (
+ AddressType,
+ Chain,
+)
+from .hwwclient import HardwareWalletClient
+from .psbt import PSBT
+
+from itertools import count
+from typing import (
+ Any,
+ Dict,
+ List,
+ Optional,
+ Union,
+)
+
+
+py_enumerate = enumerate
+
+
+# Get the client for the device
+def get_client(device_type: str, device_path: str, password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN) -> Optional[HardwareWalletClient]:
+ """
+ Returns a HardwareWalletClient for the given device type at the device path
+
+ :param device_type: The type of device
+ :param device_path: The path specifying where the device can be accessed as returned by :func:`~enumerate`
+ :param password: The password to use for this device
+ :param expert: Whether the device should be opened in expert mode (prints more information for some commands)
+ :param chain: The Chain this client will be using
+ :return: A :class:`~hwilib.hwwclient.HardwareWalletClient` to interact with the device
+ :raises: UnknownDeviceError: if the device type is not known by HWI
+ """
+
+ device_type = device_type.split('_')[0]
+ class_name = device_type.capitalize()
+ module = device_type.lower()
+
+ client: Optional[HardwareWalletClient] = None
+ try:
+ imported_dev = importlib.import_module('.devices.' + module, __package__)
+ client_constructor = getattr(imported_dev, class_name + 'Client')
+ client = client_constructor(device_path, password, expert, chain)
+ except ImportError:
+ if client:
+ client.close()
+ raise UnknownDeviceError('Unknown device type specified')
+
+ return client
+
+# Get a list of all available hardware wallets
+def enumerate(password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN, allow_emulators: bool = False) -> List[Dict[str, Any]]:
+ """
+ Enumerate all of the devices that HWI can potentially access.
+
+ :param password: The password to use for devices which take passwords from the host.
+ :return: A list of devices for which clients can be created for.
+ """
+
+ result: List[Dict[str, Any]] = []
+
+ for module in all_devs:
+ try:
+ imported_dev = importlib.import_module('.devices.' + module, __package__)
+ result.extend(imported_dev.enumerate(password, expert, chain, allow_emulators))
+ except ImportError as e:
+ # Warn for ImportErrors, but largely ignore them to allow users not install
+ # all device dependencies if only one or some devices are wanted.
+ logging.warning(f"{e}, required for {module}. Ignore if you do not want this device.")
+ pass
+ return result
+
+# Fingerprint or device type required
+def find_device(
+ password: Optional[str] = None,
+ device_type: Optional[str] = None,
+ fingerprint: Optional[str] = None,
+ expert: bool = False,
+ chain: Chain = Chain.MAIN,
+ allow_emulators: bool = False,
+) -> Optional[HardwareWalletClient]:
+ """
+ Find a device from the device type or fingerprint and get a client to access it.
+ This is used as an alternative to :func:`~get_client` if the device path is not known.
+
+ :param password: A password that may be needed to access the device if it can take passwords from the host
+ :param device_type: The type of device. The client returned will be for this type of device.
+ If not provided, the fingerprint must be provided
+ :param fingerprint: The fingerprint of the master public key for the device.
+ The client returned will have a master public key fingerprint matching this.
+ If not provided, device_type must be provided.
+ :param expert: Whether the device should be opened in expert mode (enables additional output for some actions)
+ :param chain: The Chain this client will be using
+ :return: A client to interact with the found device
+ """
+
+ devices = enumerate(password, expert, chain, allow_emulators)
+ for d in devices:
+ if device_type is not None and d['type'] != device_type and d['model'] != device_type:
+ continue
+ client = None
+ try:
+ assert isinstance(d["type"], str)
+ assert isinstance(d["path"], str)
+ client = get_client(d['type'], d['path'], password, expert, chain)
+ if client is None:
+ raise Exception()
+
+ if fingerprint:
+ master_fpr = d.get('fingerprint', None)
+ if master_fpr is None:
+ master_fpr = client.get_master_fingerprint().hex()
+
+ if master_fpr != fingerprint:
+ client.close()
+ continue
+ return client
+ except Exception:
+ if client:
+ client.close()
+ pass # Ignore things we wouldn't get fingerprints for
+ return None
+
+def getmasterxpub(client: HardwareWalletClient, addrtype: AddressType = AddressType.WIT, account: int = 0) -> Dict[str, str]:
+ """
+ Get the master extended public key from a client
+
+ :param client: The client to interact with
+ :return: A dictionary containing the public key at the ``m/44'/0'/0'`` derivation path.
+ Returned as ``{"xpub": <xpub string>}``.
+ """
+ return {"xpub": client.get_master_xpub(addrtype, account).to_string()}
+
+def signtx(client: HardwareWalletClient, psbt: str) -> Dict[str, Union[bool, str]]:
+ """
+ Sign a Partially Signed Bitcoin Transaction (PSBT) with the client.
+
+ :param client: The client to interact with
+ :param psbt: The PSBT to sign
+ :return: A dictionary containing the processed PSBT serialized in Base64.
+ Returned as ``{"psbt": <base64 psbt string>}``.
+ """
+ # Deserialize the transaction
+ tx = PSBT()
+ tx.deserialize(psbt)
+ result = client.sign_tx(tx).serialize()
+ return {"psbt": result, "signed": result != psbt}
+
+def getxpub(client: HardwareWalletClient, path: str, expert: bool = False) -> Dict[str, Any]:
+ """
+ Get the master public key at a path from a client
+
+ :param client: The client to interact with
+ :param path: The derivation path for the public key to retrieve
+ :param expert: Whether to provide more information intended for experts.
+ :return: A dictionary containing the public key at the ``bip32_path``.
+ With expert mode, the information contained within the xpub are decoded and displayed.
+ Returned as ``{"xpub": <xpub string>}``.
+ """
+ xpub = client.get_pubkey_at_path(path)
+ result: Dict[str, Any] = {"xpub": xpub.to_string()}
+ if expert:
+ result.update(xpub.get_printable_dict())
+ return result
+
+def signmessage(client: HardwareWalletClient, message: str, path: str) -> Dict[str, str]:
+ """
+ Sign a message using the key at the derivation path with the client.
+
+ The message will be signed using the Bitcoin signed message standard used by Bitcoin Core.
+ The message can be either a string which is then encoded to bytes, or bytes.
+
+ :param client: The client to interact with
+ :param message: The message to sign
+ :param path: The derivation path for the key to sign with
+ :return: A dictionary containing the signature.
+ Returned as ``{"signature": <base64 signature string>}``.
+ """
+ return {"signature": client.sign_message(message, path)}
+
+def getkeypool_inner(
+ client: HardwareWalletClient,
+ path: str,
+ start: int,
+ end: int,
+ internal: bool = False,
+ keypool: bool = True,
+ account: int = 0,
+ addr_type: AddressType = AddressType.WIT
+) -> List[Dict[str, Any]]:
+ """
+ :meta private:
+
+ Construct a single dictionary that specifies a single descriptor and the extra fields needed for ``importmulti`` or ``importdescriptors`` to import it.
+
+ :param path: The derivation path for the key in the descriptor
+ :param start: The start index of the range, inclusive
+ :param end: The end index of the range, inclusive
+ :param internal: Whether to specify this import is change
+ :param keypool: Whether to specify this import should be added to the keypool
+ :param account: The BIP 44 account to use if ``path`` is not specified
+ :param addr_type: The type of address the descriptor should create
+ """
+ master_fpr = client.get_master_fingerprint()
+
+ desc = getdescriptor(client, master_fpr, path, internal, addr_type, account, start, end)
+
+ if not isinstance(desc, Descriptor):
+ return desc
+
+ this_import: Dict[str, Any] = {}
+
+ this_import['desc'] = desc.to_string()
+ this_import['range'] = [start, end]
+ this_import['timestamp'] = 'now'
+ this_import['internal'] = internal
+ this_import['keypool'] = keypool
+ this_import['active'] = keypool
+ this_import['watchonly'] = True
+ return [this_import]
+
+def getdescriptor(
+ client: HardwareWalletClient,
+ master_fpr: bytes,
+ path: Optional[str] = None,
+ internal: bool = False,
+ addr_type: AddressType = AddressType.WIT,
+ account: int = 0,
+ start: Optional[int] = None,
+ end: Optional[int] = None
+) -> Descriptor:
+ """
+ Get a descriptor from the client.
+
+ :param client: The client to interact with
+ :param master_fpr: The hex string for the master fingerprint of the device to use in the descriptor
+ :param path: The derivation path for the xpub from which additional keys will be derived.
+ :param internal: Whether the dictionary should indicate that the descriptor should be for change addresses
+ :param addr_type: The type of address the descriptor should create
+ :param account: The BIP 44 account to use if ``path`` is not specified
+ :param start: The start of the range to import, inclusive
+ :param end: The end of the range to import, inclusive
+ :return: The descriptor constructed given the above arguments and key fetched from the device
+ :raises: BadArgumentError: if an argument is malformed or missing.
+ """
+
+ parsed_path = []
+ if not path:
+ # Purpose
+ parsed_path.append(H_(get_bip44_purpose(addr_type)))
+
+ # Coin type
+ parsed_path.append(H_(get_bip44_chain(client.chain)))
+
+ # Account
+ parsed_path.append(H_(account))
+
+ # Receive or change
+ if internal:
+ parsed_path.append(1)
+ else:
+ parsed_path.append(0)
+ else:
+ if path[0] != "m":
+ raise BadArgumentError("Path must start with m/")
+ if path[-1] != "*":
+ raise BadArgumentError("Path must end with /*")
+ parsed_path = parse_path(path[:-2])
+
+ # Find the last hardened derivation:
+ for i, p in zip(count(len(parsed_path) - 1, -1), reversed(parsed_path)):
+ if is_hardened(p):
+ break
+ i += 1
+
+ origin = KeyOriginInfo(master_fpr, parsed_path[:i])
+ path_base = origin.get_derivation_path()
+
+ path_suffix = ""
+ for p in parsed_path[i:]:
+ hardened = is_hardened(p)
+ p &= ~HARDENED_FLAG
+ path_suffix += "/{}{}".format(p, "h" if hardened else "")
+ path_suffix += "/*"
+
+ # Get the key at the base
+ if client.xpub_cache.get(path_base) is None:
+ client.xpub_cache[path_base] = client.get_pubkey_at_path(path_base).to_string()
+
+ pubkey = PubkeyProvider(origin, client.xpub_cache.get(path_base, ""), path_suffix)
+ if addr_type is AddressType.LEGACY:
+ return PKHDescriptor(pubkey)
+ elif addr_type is AddressType.SH_WIT:
+ return SHDescriptor(WPKHDescriptor(pubkey))
+ elif addr_type is AddressType.WIT:
+ return WPKHDescriptor(pubkey)
+ elif addr_type is AddressType.TAP:
+ if not client.can_sign_taproot():
+ raise UnavailableActionError("Device does not support Taproot")
+ return TRDescriptor(pubkey)
+ else:
+ raise ValueError("Unknown address type")
+
+def getkeypool(
+ client: HardwareWalletClient,
+ path: str,
+ start: int,
+ end: int,
+ internal: bool = False,
+ keypool: bool = True,
+ account: int = 0,
+ addr_type: AddressType = AddressType.WIT,
+ addr_all: bool = False
+) -> List[Dict[str, Any]]:
+ """
+ Get a dictionary which can be passed to Bitcoin Core's ``importmulti`` or ``importdescriptors`` RPCs to import a watchonly wallet based on the client.
+ By default, a descriptor for legacy addresses is returned.
+
+ :param client: The client to interact with
+ :param path: The derivation path for the xpub from which additional keys will be derived.
+ :param start: The start of the range to import, inclusive
+ :param end: The end of the range to import, inclusive
+ :param internal: Whether the dictionary should indicate that the descriptor should be for change addresses
+ :param keypool: Whether the dictionary should indicate that the dsecriptor should be added to the Bitcoin Core keypool/addresspool
+ :param account: The BIP 44 account to use if ``path`` is not specified
+ :param addr_type: The address type
+ :param addr_all: Whether to return a multiple descriptors for every address type
+ :return: The dictionary containing the descriptor and all of the arguments for ``importmulti`` or ``importdescriptors``
+ :raises: BadArgumentError: if an argument is malformed or missing.
+ """
+ supports_taproot = client.can_sign_taproot()
+
+ addr_types = [addr_type]
+ if addr_all:
+ addr_types = list(AddressType)
+ elif not supports_taproot and addr_type == AddressType.TAP:
+ raise UnavailableActionError("Device does not support Taproot")
+
+ if not supports_taproot and AddressType.TAP in addr_types:
+ del addr_types[addr_types.index(AddressType.TAP)]
+
+ # When no specific path or internal-ness is specified, create standard types
+ chains: List[Dict[str, Any]] = []
+ if path is None and not internal:
+ for addr_type in addr_types:
+ for internal_addr in [False, True]:
+ chains = chains + getkeypool_inner(client, None, start, end, internal_addr, keypool, account, addr_type)
+ return chains
+ else:
+ assert len(addr_types) == 1
+ return getkeypool_inner(client, path, start, end, internal, keypool, account, addr_types[0])
+
+
+def getdescriptors(
+ client: HardwareWalletClient,
+ account: int = 0
+) -> Dict[str, List[str]]:
+ """
+ Get descriptors from the client.
+
+ :param client: The client to interact with
+ :param account: The BIP 44 account to use
+ :return: Multiple descriptors from the device matching the BIP 44 standard paths and the given ``account``.
+ :raises: BadArgumentError: if an argument is malformed or missing.
+ """
+ master_fpr = client.get_master_fingerprint()
+
+ result = {}
+
+ for internal in [False, True]:
+ descriptors = []
+ for addr_type in list(AddressType):
+ try:
+ desc = getdescriptor(client, master_fpr=master_fpr, internal=internal, addr_type=addr_type, account=account)
+ except UnavailableActionError:
+ # Device does not support this address type or network. Skip.
+ continue
+ if not isinstance(desc, Descriptor):
+ return desc
+ descriptors.append(desc.to_string())
+ if internal:
+ result["internal"] = descriptors
+ else:
+ result["receive"] = descriptors
+
+ return result
+
+def displayaddress(
+ client: HardwareWalletClient,
+ path: Optional[str] = None,
+ desc: Optional[str] = None,
+ addr_type: AddressType = AddressType.WIT
+) -> Dict[str, str]:
+ """
+ Display an address on the device for client.
+ The address can be specified by the path with additional parameters, or by a descriptor.
+
+ :param client: The client to interact with
+ :param path: The path of the address to display. Mutually exclusive with ``desc``
+ :param desc: The descriptor to display the address for. Mutually exclusive with ``path``
+ :param addr_type: The address type to return. Only works with ``path``
+ :return: A dictionary containing the address displayed.
+ Returned as ``{"address": <base58 or bech32 address string>}``.
+ :raises: BadArgumentError: if an argument is malformed, missing, or conflicts.
+ """
+ if path is not None:
+ return {"address": client.display_singlesig_address(path, addr_type)}
+ elif desc is not None:
+ descriptor = parse_descriptor(desc)
+ addr_type = AddressType.LEGACY
+ is_sh = isinstance(descriptor, SHDescriptor)
+ is_wsh = isinstance(descriptor, WSHDescriptor)
+ if is_sh or is_wsh:
+ assert len(descriptor.subdescriptors) == 1
+ descriptor = descriptor.subdescriptors[0]
+ if isinstance(descriptor, WSHDescriptor):
+ is_wsh = True
+ assert len(descriptor.subdescriptors) == 1
+ descriptor = descriptor.subdescriptors[0]
+ if isinstance(descriptor, MultisigDescriptor):
+ if is_sh and is_wsh:
+ addr_type = AddressType.SH_WIT
+ elif not is_sh and is_wsh:
+ addr_type = AddressType.WIT
+ return {"address": client.display_multisig_address(addr_type, descriptor)}
+ is_wpkh = isinstance(descriptor, WPKHDescriptor)
+ if isinstance(descriptor, PKHDescriptor) or is_wpkh or isinstance(descriptor, TRDescriptor):
+ pubkey = descriptor.pubkeys[0]
+ if pubkey.origin is None:
+ raise BadArgumentError(f"Descriptor missing origin info: {desc}")
+ if pubkey.origin.fingerprint != client.get_master_fingerprint():
+ raise BadArgumentError(f"Descriptor fingerprint does not match device: {desc}")
+ xpub = client.get_pubkey_at_path(pubkey.origin.get_derivation_path()).to_string()
+ if pubkey.pubkey != xpub and pubkey.pubkey != xpub_to_pub_hex(xpub) and pubkey.pubkey != xpub_to_xonly_pub_hex(xpub):
+ raise BadArgumentError(f"Key in descriptor does not match device: {desc}")
+ if is_sh and is_wpkh:
+ addr_type = AddressType.SH_WIT
+ elif not is_sh and is_wpkh:
+ addr_type = AddressType.WIT
+ elif isinstance(descriptor, TRDescriptor):
+ addr_type = AddressType.TAP
+ return {"address": client.display_singlesig_address(pubkey.get_full_derivation_path(0), addr_type)}
+ raise BadArgumentError("Missing both path and descriptor")
+
+def setup_device(client: HardwareWalletClient, label: str = "", backup_passphrase: str = "") -> Dict[str, bool]:
+ """
+ Setup a device that has not yet been initialized.
+
+ :param client: The client to interact with
+ :param label: The label to apply to the newly setup device
+ :param backup_passphrase: The passphrase to use for the backup, if backups are encrypted for that device
+ :return: A dictionary with the ``success`` key.
+ """
+ return {"success": client.setup_device(label, backup_passphrase)}
+
+def wipe_device(client: HardwareWalletClient) -> Dict[str, bool]:
+ """
+ Wipe a device
+
+ :param client: The client to interact with
+ :return: A dictionary with the ``success`` key.
+ """
+ return {"success": client.wipe_device()}
+
+def restore_device(client: HardwareWalletClient, label: str = "", word_count: int = 24) -> Dict[str, bool]:
+ """
+ Restore a backup to a device that has not yet been initialized.
+
+ :param client: The client to interact with
+ :param label: The label to apply to the newly setup device
+ :param word_count: The number of words in the recovery phrase
+ :return: A dictionary with the ``success`` key.
+ """
+ return {"success": client.restore_device(label, word_count)}
+
+def backup_device(client: HardwareWalletClient, label: str = "", backup_passphrase: str = "") -> Dict[str, bool]:
+ """
+ Create a backup of the device
+
+ :param client: The client to interact with
+ :param label: The label to apply to the newly setup device
+ :param backup_passphrase: The passphrase to use for the backup, if backups are encrypted for that device
+ :return: A dictionary with the ``success`` key.
+ """
+ return {"success": client.backup_device(label, backup_passphrase)}
+
+def prompt_pin(client: HardwareWalletClient) -> Dict[str, bool]:
+ """
+ Trigger the device to show the setup for PIN entry.
+
+ :param client: The client to interact with
+ :return: A dictionary with the ``success`` key.
+ """
+ return {"success": client.prompt_pin()}
+
+def send_pin(client: HardwareWalletClient, pin: str) -> Dict[str, bool]:
+ """
+ Send a PIN to the device after :func:`prompt_pin` has been called.
+
+ :param client: The client to interact with
+ :param pin: The PIN to send
+ :return: A dictionary with the ``success`` key.
+ """
+ return {"success": client.send_pin(pin)}
+
+def toggle_passphrase(client: HardwareWalletClient) -> Dict[str, bool]:
+ """
+ Toggle whether the device is using a BIP 39 passphrase.
+
+ :param client: The client to interact with
+ :return: A dictionary with the ``success`` key.
+ """
+ return {"success": client.toggle_passphrase()}
+
+def install_udev_rules(source: str, location: str) -> Dict[str, bool]:
+ """
+ Install the udev rules to the local machine.
+ The rules will be copied from the source to the location.
+ ``udevadm`` will also be triggered and the rules reloaded so that the devices can be plugged in and used immediately.
+ A ``plugdev`` group will also be created if it does not exist and the user will be added to it.
+
+ The recommended source location is ``hwilib/udev``. The recommended destination location is ``/etc/udev/rules.d``
+
+ This function is equivalent to::
+
+ sudo cp hwilib/udev/*rules /etc/udev/rules.d/
+ sudo udevadm trigger
+ sudo udevadm control --reload-rules
+ sudo groupadd plugdev
+ sudo usermod -aG plugdev `whoami`
+
+ :param source: The directory containing the udev rules to install
+ :param location: The directory to install the udev rules to
+ :return: A dictionary with the ``success`` key.
+ :raises: NotImplementedError: if udev rules cannot be installed on this system, i.e. it is not linux.
+ """
+ if platform.system() == "Linux":
+ from .udevinstaller import UDevInstaller
+ return {"success": UDevInstaller.install(source, location)}
+ raise NotImplementedError("udev rules are not needed on your platform")
diff --git a/hwilib/common.py b/hwilib/common.py
new file mode 100644
index 0000000..0c5c006
--- /dev/null
+++ b/hwilib/common.py
@@ -0,0 +1,99 @@
+"""
+Common Classes and Utilities
+****************************
+"""
+
+import hashlib
+
+from enum import Enum
+
+from typing import Union
+
+
+class Chain(Enum):
+ """
+ The blockchain network to use
+ """
+ MAIN = 0 #: Bitcoin Main network
+ TEST = 1 #: Bitcoin Test network
+ REGTEST = 2 #: Bitcoin Core Regression Test network
+ SIGNET = 3 #: Bitcoin Signet
+ TESTNET4 = 4 #: Bitcoin Test network
+
+ def __str__(self) -> str:
+ return str(self.name).lower()
+
+ def __repr__(self) -> str:
+ return str(self)
+
+ @staticmethod
+ def argparse(s: str) -> Union['Chain', str]:
+ try:
+ return Chain[s.upper()]
+ except KeyError:
+ return s
+
+
+class AddressType(Enum):
+ """
+ The type of address to use
+ """
+ LEGACY = 1 #: Legacy address type. P2PKH for single sig, P2SH for scripts.
+ WIT = 2 #: Native segwit v0 address type. P2WPKH for single sig, P2WSH for scripts.
+ SH_WIT = 3 #: Nested segwit v0 address type. P2SH-P2WPKH for single sig, P2SH-P2WSH for scripts.
+ TAP = 4 #: Segwit v1 Taproot address type. P2TR always.
+
+ def __str__(self) -> str:
+ return str(self.name).lower()
+
+ def __repr__(self) -> str:
+ return str(self)
+
+ @staticmethod
+ def argparse(s: str) -> Union['AddressType', str]:
+ try:
+ return AddressType[s.upper()]
+ except KeyError:
+ return s
+
+
+def sha256(s: bytes) -> bytes:
+ """
+ Perform a single SHA256 hash.
+
+ :param s: Bytes to hash
+ :return: The hash
+ """
+ return hashlib.new('sha256', s).digest()
+
+
+def ripemd160(s: bytes) -> bytes:
+ """
+ Perform a single RIPEMD160 hash.
+
+ :param s: Bytes to hash
+ :return: The hash
+ """
+ return hashlib.new('ripemd160', s).digest()
+
+
+def hash256(s: bytes) -> bytes:
+ """
+ Perform a double SHA256 hash.
+ A SHA256 is performed on the input, and then a second
+ SHA256 is performed on the result of the first SHA256
+
+ :param s: Bytes to hash
+ :return: The hash
+ """
+ return sha256(sha256(s))
+
+
+def hash160(s: bytes) -> bytes:
+ """
+ perform a single SHA256 hash followed by a single RIPEMD160 hash on the result of the SHA256 hash.
+
+ :param s: Bytes to hash
+ :return: The hash
+ """
+ return ripemd160(sha256(s))
diff --git a/hwilib/descriptor.py b/hwilib/descriptor.py
new file mode 100644
index 0000000..4e743fd
--- /dev/null
+++ b/hwilib/descriptor.py
@@ -0,0 +1,639 @@
+"""
+Output Script Descriptors
+*************************
+
+HWI has a more limited implementation of descriptors.
+See `Bitcoin Core's documentation <https://github.com/bitcoin/bitcoin/blob/master/doc/descriptors.md>`_ for more details on descriptors.
+
+This implementation only supports ``sh()``, ``wsh()``, ``pkh()``, ``wpkh()``, ``multi()``, and ``sortedmulti()`` descriptors.
+Descriptors can be parsed, however the actual scripts are not generated.
+"""
+
+
+from .key import ExtendedKey, KeyOriginInfo, parse_path
+from .common import hash160, sha256
+
+from binascii import unhexlify
+from collections import namedtuple
+from enum import Enum
+from typing import (
+ List,
+ Optional,
+ Tuple,
+)
+
+
+MAX_TAPROOT_NODES = 128
+
+
+ExpandedScripts = namedtuple("ExpandedScripts", ["output_script", "redeem_script", "witness_script"])
+
+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
+
+def DescriptorChecksum(desc: str) -> str:
+ """
+ Compute the checksum for a descriptor
+
+ :param desc: The descriptor string to compute a checksum for
+ :return: A checksum
+ """
+ INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ "
+ CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
+
+ c = 1
+ cls = 0
+ clscount = 0
+ for ch in desc:
+ pos = INPUT_CHARSET.find(ch)
+ if pos == -1:
+ 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 if the pubkey is an extended pubkey
+ """
+ self.origin = origin
+ self.pubkey = pubkey
+ self.deriv_path = deriv_path
+
+ # 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
+ self.extkey = ExtendedKey.deserialize(self.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, hardened_char: str = "h") -> 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(hardened_char))
+ s += self.pubkey
+ if self.deriv_path:
+ s += self.deriv_path
+ return s
+
+ def get_pubkey_bytes(self, pos: int) -> bytes:
+ if self.extkey is not None:
+ if self.deriv_path is not None:
+ path_str = self.deriv_path[1:]
+ if path_str[-1] == "*":
+ path_str = path_str[:-1] + str(pos)
+ path = parse_path(path_str)
+ child_key = self.extkey.derive_pub_path(path)
+ return child_key.pubkey
+ else:
+ return self.extkey.pubkey
+ return unhexlify(self.pubkey)
+
+ def get_full_derivation_path(self, pos: int) -> str:
+ """
+ Returns the full derivation path at the given position, including the origin
+ """
+ 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: int) -> 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
+ """
+ path: List[int] = self.origin.get_full_int_list() if self.origin is not None else []
+ if self.deriv_path is not None:
+ der_split = self.deriv_path.split("/")
+ for p in der_split:
+ if not p:
+ continue
+ if p == "*":
+ i = pos
+ elif p[-1] in "'phHP":
+ assert len(p) >= 2
+ i = int(p[:-1]) | 0x80000000
+ else:
+ i = int(p)
+ path.append(i)
+ return path
+
+ def __lt__(self, other: 'PubkeyProvider') -> bool:
+ return self.pubkey < other.pubkey
+
+
+class Descriptor(object):
+ r"""
+ An abstract class for Descriptors themselves.
+ Descriptors can contain multiple :class:`PubkeyProvider`\ s and multiple ``Descriptor`` as subdescriptors.
+ """
+ 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, hardened_char: str = "h") -> str:
+ """
+ Serializes the descriptor as a string without the descriptor checksum
+
+ :return: The descriptor string
+ """
+ return "{}({}{})".format(
+ self.name,
+ ",".join([p.to_string(hardened_char) for p in self.pubkeys]),
+ self.subdescriptors[0].to_string_no_checksum(hardened_char) if len(self.subdescriptors) > 0 else ""
+ )
+
+ def to_string(self, hardened_char: str = "h") -> str:
+ """
+ Serializes the descriptor as a string with the checksum
+
+ :return: The descriptor with a checksum
+ """
+ return AddChecksum(self.to_string_no_checksum(hardened_char))
+
+ def expand(self, pos: int) -> "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")
+
+
+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")
+
+
+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: int) -> "ExpandedScripts":
+ script = b"\x76\xa9\x14" + hash160(self.pubkeys[0].get_pubkey_bytes(pos)) + b"\x88\xac"
+ return ExpandedScripts(script, None, None)
+
+
+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: int) -> "ExpandedScripts":
+ script = b"\x00\x14" + hash160(self.pubkeys[0].get_pubkey_bytes(pos))
+ return ExpandedScripts(script, None, None)
+
+
+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")
+ self.thresh = thresh
+ self.is_sorted = is_sorted
+ if self.is_sorted:
+ self.pubkeys.sort()
+
+ def to_string_no_checksum(self, hardened_char: str = "h") -> str:
+ return "{}({},{})".format(self.name, self.thresh, ",".join([p.to_string(hardened_char) for p in self.pubkeys]))
+
+ def expand(self, pos: int) -> "ExpandedScripts":
+ if self.thresh > 16:
+ m = b"\x01" + self.thresh.to_bytes(1, "big")
+ else:
+ m = (self.thresh + 0x50).to_bytes(1, "big") if self.thresh > 0 else b"\x00"
+ n = (len(self.pubkeys) + 0x50).to_bytes(1, "big") if len(self.pubkeys) > 0 else b"\x00"
+ script: bytes = m
+ der_pks = [p.get_pubkey_bytes(pos) for p in self.pubkeys]
+ if self.is_sorted:
+ der_pks.sort()
+ for pk in der_pks:
+ script += len(pk).to_bytes(1, "big") + pk
+ script += n + b"\xae"
+
+ return ExpandedScripts(script, None, None)
+
+
+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: int) -> "ExpandedScripts":
+ assert len(self.subdescriptors) == 1
+ redeem_script, _, witness_script = self.subdescriptors[0].expand(pos)
+ script = b"\xa9\x14" + hash160(redeem_script) + b"\x87"
+ return ExpandedScripts(script, redeem_script, witness_script)
+
+
+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: int) -> "ExpandedScripts":
+ assert len(self.subdescriptors) == 1
+ witness_script, _, _ = self.subdescriptors[0].expand(pos)
+ script = b"\x00\x20" + sha256(witness_script)
+ return ExpandedScripts(script, None, witness_script)
+
+
+class TRDescriptor(Descriptor):
+ """
+ A descriptor for ``tr()`` descriptors
+ """
+ def __init__(
+ self,
+ internal_key: 'PubkeyProvider',
+ subdescriptors: List['Descriptor'] = [],
+ depths: List[int] = []
+ ) -> None:
+ r"""
+ :param internal_key: The :class:`PubkeyProvider` that is the internal key for this descriptor
+ :param subdescriptors: The :class:`Descriptor`\ s that are the leaf scripts for this descriptor
+ :param depths: The depths of the leaf scripts in the same order as `subdescriptors`
+ """
+ super().__init__([internal_key], subdescriptors, "tr")
+ self.depths = depths
+
+ def to_string_no_checksum(self, hardened_char: str = "h") -> str:
+ r = f"{self.name}({self.pubkeys[0].to_string(hardened_char)}"
+ path: List[bool] = [] # Track left or right for each depth
+ for p, depth in enumerate(self.depths):
+ r += ","
+ while len(path) <= depth:
+ if len(path) > 0:
+ r += "{"
+ path.append(False)
+ r += self.subdescriptors[p].to_string_no_checksum(hardened_char)
+ while len(path) > 0 and path[-1]:
+ if len(path) > 0:
+ r += "}"
+ path.pop()
+ if len(path) > 0:
+ path[-1] = True
+ r += ")"
+ return r
+
+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) -> 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:]
+ return PubkeyProvider.parse(expr[:end]), 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 = 1
+ """The top level, not within any descriptor"""
+
+ P2SH = 2
+ """Within a ``sh()`` descriptor"""
+
+ P2WSH = 3
+ """Within a ``wsh()`` descriptor"""
+
+ P2TR = 4
+ """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)
+ 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)
+ 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)
+ pubkeys.append(pubkey)
+ if len(pubkeys) == 0 or len(pubkeys) > 16:
+ raise ValueError("Cannot have {} keys in a multisig; must have between 1 and 16 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)
+ 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, _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, _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)
+ subscripts = []
+ depths = []
+ if expr:
+ # Path from top of the tree to what we're currently processing.
+ # branches[i] == False: left branch in the i'th step from the top
+ # branches[i] == true: right branch
+ branches = []
+ while True:
+ # Process open braces
+ while True:
+ try:
+ expr = _get_const(expr, "{")
+ branches.append(False)
+ except ValueError:
+ break
+ if len(branches) > MAX_TAPROOT_NODES:
+ raise ValueError("tr() supports at most {MAX_TAPROOT_NODES} nesting levels")
+ # Process script expression
+ sarg, expr = _get_expr(expr)
+ subscripts.append(_parse_descriptor(sarg, _ParseDescriptorContext.P2TR))
+ depths.append(len(branches))
+ # Process closing braces
+ while len(branches) > 0 and branches[-1]:
+ expr = _get_const(expr, "}")
+ branches.pop()
+ # If we're at the end of a left branch, expect a comma
+ if len(branches) > 0 and not branches[-1]:
+ expr = _get_const(expr, ",")
+ branches[-1] = True
+
+ if len(branches) == 0:
+ break
+ return TRDescriptor(internal_key, subscripts, depths)
+ 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, _ParseDescriptorContext.TOP)
diff --git a/hwilib/devices/__init__.py b/hwilib/devices/__init__.py
new file mode 100644
index 0000000..77fa0ff
--- /dev/null
+++ b/hwilib/devices/__init__.py
@@ -0,0 +1,9 @@
+__all__ = [
+ 'trezor',
+ 'ledger',
+ 'keepkey',
+ 'digitalbitbox',
+ 'coldcard',
+ 'bitbox02',
+ 'jade'
+]
diff --git a/hwilib/devices/bitbox02.py b/hwilib/devices/bitbox02.py
new file mode 100644
index 0000000..cc6b783
--- /dev/null
+++ b/hwilib/devices/bitbox02.py
@@ -0,0 +1,964 @@
+"""
+BitBox02
+********
+"""
+
+from typing import (
+ cast,
+ Any,
+ Callable,
+ Dict,
+ Optional,
+ Mapping,
+ Union,
+ Tuple,
+ List,
+ Sequence,
+ TypeVar,
+)
+import base64
+import builtins
+import sys
+import socket
+from functools import wraps
+
+from .._base58 import decode_check, encode_check
+from ..descriptor import MultisigDescriptor
+from ..hwwclient import HardwareWalletClient
+from ..key import ExtendedKey
+from .._script import (
+ is_p2pkh,
+ is_p2wpkh,
+ is_p2wsh,
+ is_p2tr,
+ parse_multisig,
+)
+from ..psbt import PSBT
+from ..tx import (
+ CTxOut,
+)
+from .._serialize import (
+ ser_uint256,
+ ser_sig_der,
+)
+from ..errors import (
+ HWWError,
+ ActionCanceledError,
+ BadArgumentError,
+ DeviceNotReadyError,
+ UnavailableActionError,
+ DEVICE_NOT_INITIALIZED,
+ handle_errors,
+ common_err_msgs,
+)
+from ..key import (
+ KeyOriginInfo,
+ parse_path,
+)
+from ..common import (
+ AddressType,
+ Chain,
+)
+
+import hid
+
+from .bitbox02_lib import util
+from .bitbox02_lib import bitbox02
+from .bitbox02_lib.communication import (
+ devices,
+ u2fhid,
+ FirmwareVersionOutdatedException,
+ Bitbox02Exception,
+ UserAbortException,
+ HARDENED,
+ ERR_GENERIC,
+)
+
+from .bitbox02_lib.communication.bitbox_api_protocol import (
+ Platform,
+ BitBox02Edition,
+ BitBoxNoiseConfig,
+)
+
+SIMULATOR_PATH = "127.0.0.1:15423"
+
+class BitBox02Error(UnavailableActionError):
+ def __init__(self, msg: str):
+ """
+ BitBox02 unexpected error. The BitBox02 does not return give granular error messages,
+ so we give hints to as what could be wrong.
+ """
+ msg = "Input error: {}. A keypath might be invalid. Supported keypaths are: ".format(
+ msg
+ )
+ msg += "m/49'/0'/<account'> for p2wpkh-p2sh; "
+ msg += "m/84'/0'/<account'> for p2wpkh; "
+ msg += "m/86'/0'/<account'> for p2tr; "
+ msg += "m/48'/0'/<account'>/2' for p2wsh multisig; "
+ msg += "m/48'/0'/<account'>/1' for p2wsh-p2sh multisig; "
+ msg += "m/48'/0'/<account'>' for any supported multisig; "
+ msg += "account can be between 0' and 99'; "
+ msg += "For address keypaths, append /0/<address index> for a receive and /1/<change index> for a change address."
+ super().__init__(msg)
+
+
+ERR_INVALID_INPUT = 101
+
+PURPOSE_P2WPKH_P2SH = 49 + HARDENED
+PURPOSE_P2WPKH = 84 + HARDENED
+PURPOSE_MULTISIG_P2WSH = 48 + HARDENED
+
+# External GUI tools using hwi.py as a command line tool to integrate hardware wallets usually do
+# not have an actual terminal for IO.
+_using_external_gui = sys.stdout is not None and not sys.stdout.isatty()
+if _using_external_gui:
+ _unpaired_errmsg = "Device not paired yet. Please pair using the BitBoxApp, then close the BitBoxApp and try again."
+else:
+ _unpaired_errmsg = "Device not paired yet. Please use any subcommand to pair"
+
+
+class SilentNoiseConfig(util.BitBoxAppNoiseConfig):
+ """
+ Used during `enumerate()`. Raises an exception if the device is unpaired.
+ Attestation check is silent.
+
+ Rationale: enumerate() should not show any dialogs.
+ """
+
+ def show_pairing(self, code: str, device_response: Callable[[], bool]) -> bool:
+ raise DeviceNotReadyError(_unpaired_errmsg)
+
+ def attestation_check(self, result: bool) -> None:
+ pass
+
+
+class CLINoiseConfig(util.BitBoxAppNoiseConfig):
+ """ Noise pairing and attestation check handling in the terminal (stdin/stdout) """
+
+ def show_pairing(self, code: str, device_response: Callable[[], bool]) -> bool:
+ if _using_external_gui:
+ # The user can't see the pairing in the terminal. The
+ # output format is also not appropriate for parsing by
+ # external tools doing inter process communication using
+ # stdin/stdout. For now, we direct the user to pair in the
+ # BitBoxApp instead.
+ raise DeviceNotReadyError(_unpaired_errmsg)
+
+ print("Please compare and confirm the pairing code on your BitBox02:")
+ print(code)
+ if not device_response():
+ return False
+ return input("Accept pairing? [y]/n: ").strip() != "n"
+
+ def attestation_check(self, result: bool) -> None:
+ if result:
+ sys.stderr.write("BitBox02 attestation check PASSED\n")
+ else:
+ sys.stderr.write("BitBox02 attestation check FAILED\n")
+ sys.stderr.write(
+ "Your BitBox02 might not be genuine. Please contact support@shiftcrypto.ch if the problem persists.\n"
+ )
+
+
+def _keypath_hardened_prefix(keypath: Sequence[int]) -> Sequence[int]:
+ for i, e in builtins.enumerate(keypath):
+ if e & HARDENED == 0:
+ return keypath[:i]
+ return keypath
+
+
+def _xpubs_equal_ignoring_version(xpub1: bytes, xpub2: bytes) -> bool:
+ """
+ Xpubs: 78 bytes. Returns true if the xpubs are equal, ignoring the 4 byte version.
+ The version is not important and allows compatibility with Electrum, which exports PSBTs with
+ xpubs using Electrum-style xpub versions.
+ """
+ return xpub1[4:] == xpub2[4:]
+
+
+def enumerate(password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN, allow_emulators: bool = False) -> List[Dict[str, Any]]:
+ """
+ Enumerate all BitBox02 devices. Bootloaders excluded.
+ """
+ result = []
+ devs = [device_info["path"].decode() for device_info in devices.get_any_bitbox02s()]
+ if allow_emulators:
+ devs.append(SIMULATOR_PATH)
+ for path in devs:
+ client = Bitbox02Client(path=path)
+ if allow_emulators and client.simulator and not client.simulator.connected:
+ continue
+ if path != SIMULATOR_PATH:
+ client.set_noise_config(SilentNoiseConfig())
+ d_data: Dict[str, object] = {}
+ bb02 = None
+ with handle_errors(common_err_msgs["enumerate"], d_data):
+ bb02 = client.init(expect_initialized=None)
+ version, platform, edition, unlocked, _ = bitbox02.BitBox02.get_info(
+ client.transport
+ )
+ if platform not in (Platform.BITBOX02, Platform.BITBOX02PLUS):
+ client.close()
+ continue
+ if edition not in (BitBox02Edition.MULTI, BitBox02Edition.BTCONLY):
+ client.close()
+ continue
+
+ assert isinstance(edition, BitBox02Edition)
+
+ d_data.update(
+ {
+ "type": "bitbox02",
+ "path": path,
+ "model": {
+ Platform.BITBOX02: {
+ BitBox02Edition.MULTI: "bitbox02_multi",
+ BitBox02Edition.BTCONLY: "bitbox02_btconly",
+ },
+ Platform.BITBOX02PLUS: {
+ BitBox02Edition.MULTI: "bitbox02_nova_multi",
+ BitBox02Edition.BTCONLY: "bitbox02_nova_btconly",
+ },
+ }[platform][edition],
+ "needs_pin_sent": False,
+ "needs_passphrase_sent": False,
+ }
+ )
+
+ if bb02 is not None:
+ with handle_errors(common_err_msgs["enumerate"], d_data):
+ if not bb02.device_info()["initialized"]:
+ raise DeviceNotReadyError(
+ "BitBox02 is not initialized. Please initialize it using the BitBoxApp."
+ )
+ elif not unlocked:
+ raise DeviceNotReadyError(
+ "Please load wallet to unlock."
+ if _using_external_gui
+ else "Please use any subcommand to unlock"
+ )
+ d_data["fingerprint"] = client.get_master_fingerprint().hex()
+
+ result.append(d_data)
+
+ client.close()
+ return result
+
+
+T = TypeVar("T", bound=Callable[..., Any])
+
+
+def bitbox02_exception(f: T) -> T:
+ """
+ Maps bitbox02 library exceptions into a HWI exceptions.
+ """
+
+ @wraps(f)
+ def func(*args, **kwargs): # type: ignore
+ """ Wraps f, mapping exceptions. """
+ try:
+ return f(*args, **kwargs)
+ except UserAbortException:
+ raise ActionCanceledError("{} canceled".format(f.__name__))
+ except Bitbox02Exception as exc:
+ if exc.code in (ERR_GENERIC, ERR_INVALID_INPUT):
+ raise BitBox02Error(str(exc))
+ raise exc
+ except FirmwareVersionOutdatedException as exc:
+ raise DeviceNotReadyError(str(exc))
+ except ValueError as e:
+ raise BadArgumentError(str(e))
+
+ return cast(T, func)
+
+class BitBox02Simulator():
+ def __init__(self) -> None:
+ self.client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ ip, port = SIMULATOR_PATH.split(":")
+ self.connected = True
+ try:
+ self.client_socket.connect((ip, int(port)))
+ except:
+ self.connected = False
+
+ def write(self, data: bytes) -> None:
+ # Messages from client are always prefixed with HID report ID(0x00), which is not expected by the simulator.
+ self.client_socket.send(data[1:])
+
+ def read(self, size: int, timeout_ms: int) -> bytes:
+ res = self.client_socket.recv(64)
+ return res
+
+ def close(self) -> None:
+ self.client_socket.close()
+
+# This class extends the HardwareWalletClient for BitBox02 specific things
+class Bitbox02Client(HardwareWalletClient):
+ def __init__(self, path: str, password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN) -> None:
+ """
+ Initializes a new BitBox02 client instance.
+ """
+ if password is not None:
+ raise BadArgumentError(
+ "The BitBox02 does not accept a passphrase from the host. Please enable the passphrase option and enter the passphrase on the device during unlock."
+ )
+ super().__init__(path, password=password, expert=expert, chain=chain)
+ self.simulator = None
+ self.noise_config: BitBoxNoiseConfig = BitBoxNoiseConfig()
+
+ if path != SIMULATOR_PATH:
+ hid_device = hid.device()
+ hid_device.open_path(path.encode())
+ self.transport = u2fhid.U2FHid(hid_device)
+ self.noise_config = CLINoiseConfig()
+ else:
+ self.simulator = BitBox02Simulator()
+ if self.simulator.connected:
+ self.transport = u2fhid.U2FHid(self.simulator)
+ self.device_path = path
+
+ # use self.init() to access self.bb02.
+ self.bb02: Optional[bitbox02.BitBox02] = None
+
+ def set_noise_config(self, noise_config: BitBoxNoiseConfig) -> None:
+ self.noise_config = noise_config
+
+ def init(self, expect_initialized: Optional[bool] = True) -> bitbox02.BitBox02:
+ if self.bb02 is not None:
+ return self.bb02
+
+ bb02 = bitbox02.BitBox02(
+ transport=self.transport,
+ # Passing None as device_info means the device will be queried for the relevant device info.
+ device_info=None,
+ noise_config=self.noise_config,
+ )
+ try:
+ bb02.check_min_version()
+ except FirmwareVersionOutdatedException as exc:
+ sys.stderr.write("WARNING: {}\n".format(exc))
+ raise
+ self.bb02 = bb02
+ is_initialized = bb02.device_info()["initialized"]
+ if expect_initialized is not None:
+ if expect_initialized:
+ if not is_initialized:
+ raise HWWError(
+ "The BitBox02 must be initialized first.",
+ DEVICE_NOT_INITIALIZED,
+ )
+ elif is_initialized:
+ raise UnavailableActionError(
+ "The BitBox02 must be wiped before setup."
+ )
+
+ return bb02
+
+ def close(self) -> None:
+ self.transport.close()
+
+ def get_master_fingerprint(self) -> bytes:
+ """
+ HWI by default retrieves the fingerprint at m/ by getting the xpub at m/0', which contains the parent fingerprint.
+ The BitBox02 does not support querying arbitrary keypaths, but has an api call return the fingerprint at m/.
+ """
+ bb02 = self.init()
+ return bb02.root_fingerprint()
+
+ def prompt_pin(self) -> bool:
+ raise UnavailableActionError(
+ "The BitBox02 does not need a PIN sent from the host"
+ )
+
+ def send_pin(self, pin: str) -> bool:
+ raise UnavailableActionError(
+ "The BitBox02 does not need a PIN sent from the host"
+ )
+
+ def _get_coin(self) -> "bitbox02.btc.BTCCoin.V":
+ if self.chain != Chain.MAIN:
+ return bitbox02.btc.TBTC
+ return bitbox02.btc.BTC
+
+ def _get_xpub(self, keypath: Sequence[int]) -> str:
+ xpub_type = (
+ bitbox02.btc.BTCPubRequest.TPUB
+ if self.chain != Chain.MAIN
+ else bitbox02.btc.BTCPubRequest.XPUB
+ )
+ return self.init().btc_xpub(
+ keypath, coin=self._get_coin(), xpub_type=xpub_type, display=False
+ )
+
+ def get_pubkey_at_path(self, bip32_path: str) -> ExtendedKey:
+ """
+ Fetch the public key at the derivation path.
+
+ The BitBox02 has strict keypath validation.
+
+ The only accepted keypaths for xpubs are (as of firmware v9.4.0):
+
+ - `m/49'/0'/<account'>` for `p2wpkh-p2sh` (segwit wrapped in P2SH)
+ - `m/84'/0'/<account'>` for `p2wpkh` (native segwit v0)
+ - `m/86'/0'/<account'>` for `p2tr` (native segwit v1)
+ - `m/48'/0'/<account'>/2'` for p2wsh multisig (native segwit v0 multisig).
+ - `m/48'/0'/<account'>/1'` for p2wsh-p2sh multisig (p2sh-wrapped segwit v0 multisig).
+ - `m/48'/0'/<account'>` for p2wsh and p2wsh-p2sh multisig.
+
+ `account'` can be between `0'` and `99'`.
+
+ For address keypaths, append `/0/<address index>` for a receive and `/1/<change index>` for a change
+ address. Up to `10000` addresses are supported.
+
+ In testnet mode, the second element must be `1'` (e.g. `m/49'/1'/...`).
+
+ Public keys for the Legacy address type (i.e. P2WPKH and P2SH multisig) derivation path is unsupported.
+ """
+ path_uint32s = parse_path(bip32_path)
+ try:
+ xpub_str = self._get_xpub(path_uint32s)
+ except Bitbox02Exception as exc:
+ raise BitBox02Error(str(exc))
+ xpub = ExtendedKey.deserialize(xpub_str)
+ return xpub
+
+ def _maybe_register_script_config(
+ self, script_config: bitbox02.btc.BTCScriptConfig, keypath: Sequence[int]
+ ) -> None:
+ bb02 = self.init()
+ is_registered = bb02.btc_is_script_config_registered(
+ self._get_coin(), script_config, keypath
+ )
+ if not is_registered:
+ bb02.btc_register_script_config(
+ coin=self._get_coin(),
+ script_config=script_config,
+ keypath=keypath,
+ name="", # enter name on the device
+ xpub_type=bitbox02.btc.BTCRegisterScriptConfigRequest.AUTO_XPUB_TPUB,
+ )
+
+ def _multisig_scriptconfig(
+ self,
+ threshold: int,
+ origin_infos: Mapping[bytes, KeyOriginInfo],
+ script_type: "bitbox02.btc.BTCScriptConfig.Multisig.ScriptType.V",
+ ) -> Tuple[bytes, bitbox02.btc.BTCScriptConfigWithKeypath]:
+ """
+ From a threshold, {xpub: KeyOriginInfo} mapping and multisig script type,
+ return our xpub and the BitBox02 multisig script config.
+ """
+ # Figure out which of the cosigners is us.
+ device_fingerprint = self.get_master_fingerprint()
+ our_xpub_index = None
+ our_account_keypath = None
+
+ xpubs: List[bytes] = []
+ for i, (xpub, keyinfo) in builtins.enumerate(origin_infos.items()):
+ xpubs.append(xpub)
+ if device_fingerprint == keyinfo.fingerprint and keyinfo.path:
+ if _xpubs_equal_ignoring_version(
+ decode_check(self._get_xpub(keyinfo.path)), xpub
+ ):
+ our_xpub_index = i
+ our_account_keypath = keyinfo.path
+
+ if our_xpub_index is None:
+ raise BadArgumentError("This BitBox02 is not one of the cosigners")
+ assert our_account_keypath
+
+ if len(xpubs) != len(set(xpubs)):
+ raise BadArgumentError("Duplicate xpubs not supported")
+
+ return (
+ xpubs[our_xpub_index],
+ bitbox02.btc.BTCScriptConfigWithKeypath(
+ script_config=bitbox02.btc.BTCScriptConfig(
+ multisig=bitbox02.btc.BTCScriptConfig.Multisig(
+ threshold=threshold,
+ xpubs=[util.parse_xpub(encode_check(xpub)) for xpub in xpubs],
+ our_xpub_index=our_xpub_index,
+ script_type=script_type,
+ )
+ ),
+ keypath=our_account_keypath,
+ ),
+ )
+
+ @bitbox02_exception
+ def display_singlesig_address(
+ self,
+ bip32_path: str,
+ addr_type: AddressType,
+ ) -> str:
+ if addr_type == AddressType.SH_WIT:
+ script_config = bitbox02.btc.BTCScriptConfig(
+ simple_type=bitbox02.btc.BTCScriptConfig.P2WPKH_P2SH
+ )
+ elif addr_type == AddressType.WIT:
+ script_config = bitbox02.btc.BTCScriptConfig(
+ simple_type=bitbox02.btc.BTCScriptConfig.P2WPKH
+ )
+ elif addr_type == AddressType.LEGACY:
+ raise UnavailableActionError(
+ "The BitBox02 does not support legacy p2pkh addresses"
+ )
+ elif addr_type == AddressType.TAP:
+ script_config = bitbox02.btc.BTCScriptConfig(
+ simple_type=bitbox02.btc.BTCScriptConfig.P2TR
+ )
+ else:
+ raise BadArgumentError("Unknown address type")
+ address = self.init().btc_address(
+ parse_path(bip32_path),
+ coin=self._get_coin(),
+ script_config=script_config,
+ display=True,
+ )
+ return address
+
+ @bitbox02_exception
+ def display_multisig_address(
+ self,
+ addr_type: AddressType,
+ multisig: MultisigDescriptor,
+ ) -> str:
+ if not multisig.is_sorted:
+ raise BadArgumentError("BitBox02 only supports sortedmulti descriptors")
+
+ path_suffixes = set(p.deriv_path for p in multisig.pubkeys)
+ if len(path_suffixes) != 1:
+ # Path suffix refers to the path after the account-level xpub, usually /<change>/<address>.
+ # The BitBox02 currently enforces that all of them are the same.
+ raise BadArgumentError("All multisig path suffixes must be the same")
+
+ # Figure out which of the cosigners is us.
+ key_origin_infos = {}
+ keypWhy this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.