Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/build-bitcoind/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ runs:
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
tar -czf bitcoind.tar.gz test/work/bitcoin/build/bin/bitcoind test/work/bitcoin/build/bin/bitcoin-cli

- uses: actions/cache/save@v4
if: github.event_name != 'pull_request' && steps.ccache-cache.outputs.cache-hit != 'true'
Expand Down
13 changes: 13 additions & 0 deletions .github/actions/test-device/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@ runs:
run: |
cd test; poetry run ./run_tests.py --${{ matrix.device }} --interface=${{ matrix.test.interface }} --device-only; cd ..

- name: Run Ledger MuSig2 scenario
if: matrix.device == 'ledger' && matrix.test.interface
shell: bash
env:
BITCOIND: ${{ github.workspace }}/test/work/bitcoin/build/bin/bitcoind
BITCOIN_CLI: ${{ github.workspace }}/test/work/bitcoin/build/bin/bitcoin-cli
HWI_REPO: ${{ github.workspace }}
LEDGER_APP_ELF: ${{ github.workspace }}/test/work/speculos/apps/btc-test.elf
PYTHON: poetry run python
SPECULOS: poetry run speculos
run: |
test/run_ledger_musig2_scenario.sh

- if: failure()
shell: bash
run: |
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/ledger-app-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ jobs:
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
git clone --branch 2.4.1 --depth 1 https://github.com/LedgerHQ/app-bitcoin.git
cd app-bitcoin
make DEBUG=1 BOLOS_SDK=$NANOX_SDK
- uses: actions/upload-artifact@v4
with:
name: ledger_app
path: app-bitcoin-new/bin/app.elf
path: app-bitcoin/bin/app.elf
6 changes: 3 additions & 3 deletions .github/workflows/ledger-legacy-app-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ jobs:
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
git clone --depth 1 https://github.com/LedgerHQ/app-bitcoin-legacy.git -b legacy-1.6.6
cd app-bitcoin-legacy
make DEBUG=1 BOLOS_SDK=$NANOSP_SDK
- uses: actions/upload-artifact@v4
with:
name: ledger_app_legacy
path: app-bitcoin/bin/app.elf
path: app-bitcoin-legacy/bin/app.elf
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Bitcoin Hardware Wallet Interface

[![Build Status](https://api.cirrus-ci.com/github/bitcoin-core/HWI.svg)](https://cirrus-ci.com/github/bitcoin-core/HWI)
[![CI](https://github.com/bitcoin-core/HWI/actions/workflows/ci.yml/badge.svg)](https://github.com/bitcoin-core/HWI/actions/workflows/ci.yml)
[![Documentation Status](https://readthedocs.org/projects/hwi/badge/?version=latest)](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.
Expand Down
99 changes: 0 additions & 99 deletions ci/cirrus.Dockerfile

This file was deleted.

2 changes: 1 addition & 1 deletion ci/py310.Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Cache break (modify this line to break cirrus' dockerfile build cache) 1
# Cache break (modify this line to break the dockerfile build cache) 1

FROM python:3.10

Expand Down
2 changes: 1 addition & 1 deletion ci/py311.Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Cache break (modify this line to break cirrus' dockerfile build cache)
# Cache break (modify this line to break the dockerfile build cache)

FROM python:3.11

Expand Down
23 changes: 0 additions & 23 deletions ci/py37.Dockerfile

This file was deleted.

1 change: 1 addition & 0 deletions docs/examples/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ Example Usage

bitcoin-core-usage
examples
musig2-ledger
walkthrough/walkthrough
100 changes: 100 additions & 0 deletions docs/examples/musig2-ledger.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
MuSig2 With Bitcoin Core And Ledger
===================================

This walkthrough describes the HWI side of a 2-of-2 MuSig2 wallet where
cosigner A is a Ledger and cosigner B is a hot key in the same Bitcoin Core
wallet. It assumes a Bitcoin Core build with BIP388 policy support and
``tr(musig(...))`` descriptors.

HWI currently supports this flow for Ledger only.

Get The Ledger Key
------------------

Use BIP87 account keys for MuSig2. For test networks the account path is
``m/87h/1h/0h``:

.. code-block:: bash

./hwi.py --chain test --fingerprint "$FP_A" getxpub "m/87h/1h/0h"

Build the key expression with origin information:

.. code-block:: bash

COSIGNER_A_KEY="[${FP_A}/87h/1h/0h]tpub..."

Create the Core wallet with ``external_signer=true``,
``disable_private_keys=false``, and ``blank=true``. Add cosigner B's hot HD key
to that wallet, derive its BIP87 account xpub, and build a descriptor like:

.. code-block:: bash

DESC_NO_CKSUM="tr(musig(${COSIGNER_A_KEY},${COSIGNER_B_KEY})/<0;1>/*)"

Register The Policy
-------------------

Bitcoin Core's ``registerpolicy`` RPC calls HWI's ``register`` command with the
BIP388 policy name, descriptor template, and one key per ``@N`` placeholder:

.. code-block:: bash

./hwi.py --chain test --fingerprint "$FP_A" register \
--name MuSigTest \
--desc "tr(musig(@0,@1)/**)" \
--key "$COSIGNER_A_KEY" \
--key "$COSIGNER_B_KEY"

The result is a deterministic policy HMAC for the device seed and policy:

.. code-block:: json

{"hmac":"85d68dad...30949f9c"}

Display A Registered Address
----------------------------

For registered policies, Bitcoin Core's ``walletdisplayaddress`` invokes HWI's
policy display mode instead of passing a single-address descriptor:

.. code-block:: bash

./hwi.py --chain test --fingerprint "$FP_A" displayaddress \
--policy-name MuSigTest \
--policy-desc "tr(musig(@0,@1)/**)" \
--key "$COSIGNER_A_KEY" \
--key "$COSIGNER_B_KEY" \
--hmac "$HMAC" \
--index 0

Use ``--change`` to display the change branch for the same index. HWI returns
the address reported by the device:

.. code-block:: json

{
"address": "tb1p...",
"index": 0,
"change": false
}

Sign
----

Core invokes HWI's ``signtx`` with the same policy arguments and HMAC:

.. code-block:: bash

./hwi.py --chain test --fingerprint "$FP_A" signtx "$PSBT" \
--policy-name MuSigTest \
--policy-desc "tr(musig(@0,@1)/**)" \
--key "$COSIGNER_A_KEY" \
--key "$COSIGNER_B_KEY" \
--hmac "$HMAC"

MuSig2 signing has two rounds. When the coordinating wallet has another
cosigner locally, Bitcoin Core can run both rounds inside one ``send`` call:
first HWI yields the Ledger's public nonce, then Core adds the hot cosigner's
nonce and partial signature, and finally HWI yields the Ledger's partial
signature. The returned PSBT is then ready for finalization by Core.
46 changes: 43 additions & 3 deletions hwilib/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
getdescriptors,
prompt_pin,
toggle_passphrase,
register,
restore_device,
send_pin,
setup_device,
Expand All @@ -22,6 +23,7 @@
)
from .common import (
AddressType,
BIP388Policy,
Chain,
)
from .errors import (
Expand Down Expand Up @@ -56,8 +58,20 @@
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 displayaddress_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, Union[bool, int, str]]:
policy = None
if args.policy_name is not None or args.policy_desc is not None or args.key is not None or args.hmac is not None or args.index is not None or args.change:
policy = BIP388Policy(
name=args.policy_name or "",
descriptor_template=args.policy_desc,
keys_info=args.key,
hmac=args.hmac,
)
return displayaddress(client, desc=args.desc, path=args.path, addr_type=args.addr_type, bip388_policy=policy, index=args.index, change=args.change)

def register_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, str]:
policy = BIP388Policy(name=args.name, descriptor_template=args.desc, keys_info=args.key)
return register(client, bip388_policy=policy)

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)
Expand Down Expand Up @@ -88,7 +102,15 @@ def signmessage_handler(args: argparse.Namespace, client: HardwareWalletClient)
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)
policy = None
if args.policy_name is not None or args.policy_desc is not None:
policy = BIP388Policy(
name=args.policy_name,
descriptor_template=args.policy_desc,
keys_info=args.key,
hmac=args.hmac
)
return signtx(client, psbt=args.psbt, bip388_policy=policy)

def wipe_device_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, bool]:
return wipe_device(client)
Expand Down Expand Up @@ -161,6 +183,11 @@ def get_parser() -> HWIArgumentParser:

signtx_parser = subparsers.add_parser('signtx', help='Sign a PSBT')
signtx_parser.add_argument('psbt', help='The Partially Signed Bitcoin Transaction to sign')
signtx_policy_group = signtx_parser.add_argument_group("BIP388 policy")
signtx_policy_group.add_argument('--policy-name', help='Registered policy name')
signtx_policy_group.add_argument('--policy-desc', help='Registered policy descriptor template')
signtx_policy_group.add_argument('--key', help='Registered policy key information', action='append')
signtx_policy_group.add_argument('--hmac', help='Registered policy hmac, obtained via register command')
signtx_parser.set_defaults(func=signtx_handler)

getxpub_parser = subparsers.add_parser('getxpub', help='Get an extended public key')
Expand Down Expand Up @@ -194,9 +221,22 @@ def get_parser() -> HWIArgumentParser:
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/*``')
group.add_argument('--policy-name', help='Registered BIP388 policy name')
displayaddr_parser.add_argument("--addr-type", help="The address type to display", type=AddressType.argparse, choices=list(AddressType), default=AddressType.WIT) # type: ignore
displayaddr_policy_group = displayaddr_parser.add_argument_group("BIP388 policy")
displayaddr_policy_group.add_argument('--policy-desc', help='Registered policy descriptor template')
displayaddr_policy_group.add_argument('--key', help='Registered policy key information', action='append')
displayaddr_policy_group.add_argument('--index', help='Address index to display from the policy', type=int)
displayaddr_policy_group.add_argument('--hmac', help='Registered policy hmac, obtained via register command')
displayaddr_policy_group.add_argument('--change', help='Display the change address for the given index', action='store_true')
displayaddr_parser.set_defaults(func=displayaddress_handler)

register_parser = subparsers.add_parser('register', help='Register a BIP388 wallet policy')
register_parser.add_argument('--name', help='Name for the policy')
register_parser.add_argument('--desc', help='Descriptor template, e.g. tr(musig(@0,@1)')
register_parser.add_argument('--key', help='Key information, e.g. [00000000/84h/0h/0h]xpub...', action='append')
register_parser.set_defaults(func=register_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='')
Expand Down
Loading
Loading