Skip to content

hwi-rs: a drop in replacement for HWI#1

Open
Sjors wants to merge 16 commits into
masterfrom
2026/04/hwi-rs
Open

hwi-rs: a drop in replacement for HWI#1
Sjors wants to merge 16 commits into
masterfrom
2026/04/hwi-rs

Conversation

@Sjors
Copy link
Copy Markdown
Owner

@Sjors Sjors commented Apr 27, 2026

Implements a limited subset of the Python HWI. Can be used as a drop-in replacement with Bitcoin Core, using -signer.

Meta note: my priority is to implemented changes in Bitcoin Core in order to get MuSig2 working (see #2). Actually replacing the Python HWI, is not really a goal. If someone wishes to do that, perhaps wizardsardine/bhwi is a better platform to build on, see wizardsardine#16 (comment). Feel free to cherry-pick from here.

Supported subcommands

Subcommand Status Notes
enumerate Outputs the HWI JSON shape on stdout.
getdescriptors --account <n> Returns {"receive": [...], "internal": [...]} (BIP44/49/84/86).
getxpub <path> Returns {"xpub": "..."} for a custom BIP32 path (e.g. m/48'/1'/0'/2').
displayaddress --desc <descriptor> Shows the address on-device; echoes {"address": "..."}.
signtx <base64-psbt> Signs a PSBT and returns {"psbt": "..."}. Use --stdin for large PSBTs.

Supported devices

Device Status
Ledger ✅ (new app only; legacy skipped)
BitBox02 ❌ TODO
Coldcard ✅ (Mk4 / Q1, firmware ≥ 6.5.0X)
Jade ❌ TODO
Specter ❌ TODO

Build

cargo build -p hwi-rs --release

The binary lives at target/release/hwi-rs.

Example

$ hwi-rs enumerate
[{"type":"ledger","model":"ledger_nano_x","label":null,"path":"...","fingerprint":"00000000","needs_pin_sent":false,"needs_passphrase_sent":false}]

Use with Bitcoin Core

Configure bitcoind/bitcoin-qt to invoke this binary as the external signer:

bitcoind -signer=/absolute/path/to/hwi-rs

@Sjors
Copy link
Copy Markdown
Owner Author

Sjors commented Apr 28, 2026

Rebased after wizardsardine#129

@Sjors Sjors force-pushed the 2026/04/hwi-rs branch 2 times, most recently from 3b60fef to 3d6b0d7 Compare April 29, 2026 10:20
@Sjors
Copy link
Copy Markdown
Owner Author

Sjors commented Apr 29, 2026

Rebased after wizardsardine#128.

@Sjors
Copy link
Copy Markdown
Owner Author

Sjors commented Apr 29, 2026

Rebased after wizardsardine#130.

Added ColdCard with 6.5.0 firmware. Just the single sig basics here. I'll try out its MuSig2 support in #2.

Sjors added 5 commits April 29, 2026 18:19
Add a CI job that builds Bitcoin Core (bitcoind + bitcoin-cli, wallet
enabled, tests/bench/GUI disabled) once per workflow run and uploads the
resulting binaries as an artifact. Subsequent commits will add jobs that
run Bitcoin Core's external-signer interface end-to-end against a hwi-rs
binary, and they all need a working bitcoind/bitcoin-cli pair.

Pinned to Bitcoin Core master with ccache + sccache caching keyed on the
ref, since regtest scenarios only need a recent build, not a tagged
release. Runs in parallel with linter / unit_tests / ledger_app.
Add an empty hwi-rs binary crate to the workspace. It parses argv via
clap and prints `--help`; no subcommands are wired up yet — those
land in the next commit. This isolates the workspace plumbing,
hwi-rs/Cargo.toml, and the hwi_rs_builder CI job from the actual HWI
verbs, which makes the diff that introduces `enumerate` smaller.

The CI builder mirrors bitcoind_builder: build once with sccache,
upload the release binary as an artifact, and `hwi-rs --help` is
exercised as a smoke test.
Pull `coldcard` 0.13.0 from crates.io into `coldcard-vendored/` and
make it a workspace member, switching the optional `async-hwi`
dependency from the registry version to a path dep. This is a
pristine vendor with no source modifications: no behavioural change
on this commit.

The vendoring sets up the next commit, which adds simulator support
and BIP86 address rendering on top.
Layer four upstream-PR-shaped additions on top of pristine 0.13.0:

- New `transport.rs` introduces a `Transport` trait abstracting the
  HID byte-pipe behind `Coldcard.cc`. The existing `hidapi`-backed
  path becomes the default impl; `UnixDatagramTransport` is a new
  impl for the `coldcard-mpy` simulator's `SOCK_DGRAM` socket at
  `/tmp/ckcc-simulator.sock`. `Coldcard.cc` becomes
  `Box<dyn Transport>` with no API impact on existing callers.

- `Coldcard::open_simulator(path, opts)` constructs a Coldcard over
  the unix socket transport, bypassing the MITM session check that
  doesn't apply to a local simulator.

- `AddressFormat::P2TR` (= `AFC_PUBKEY|AFC_SEGWIT|AFC_BECH32M = 0x23`)
  and the matching `AFC_BECH32M` constant. The simulator firmware
  already accepts P2TR addresses via the `address` ckcc message; only
  the Rust enum was missing the variant.

- `Coldcard::sim_keypress(keys)` sends the simulator-only `XKEY` USB
  test command (handled by `usb_test_commands.do_usb_command` in the
  `coldcard-mpy` build, ignored by production firmware). Used by the
  `hwi-rs` integration scenario to drive the on-device sign prompt
  without a human at the keyboard.

These changes are intentionally scoped so they could be upstreamed
to `alfred-hodler/rust-coldcard` as a single PR.
First useful subcommand: `hwi-rs enumerate` lists connected/known
signers as a JSON array compatible with Bitcoin Core's
`enumeratesigners` RPC. Backends:

  * Ledger over USB HID (real device).
  * In-process software mock, enabled with `HWI_RS_MOCK=1`. Backed
    by BIP32 test vector 1 so future `signtx` support can produce
    real signatures. The mock is device-agnostic — it always reports
    `type:"mock"` / `model:"mock"` regardless of which physical
    signer it is standing in for. Per-device CI matrix entries
    differ only in the integration script that drives them, not in
    what the mock claims to be.

Plus two integration scripts driven from CI:

  * `tests/run-core-scenario.sh` — bitcoind regtest external-signer
    round trip against the mock.
  * `tests/run-core-scenario-speculos.sh` — same scenario against
    speculos.

Wired into the workflow as two new jobs (`mock_integration`,
`speculos_integration`). Both consume the bitcoind / hwi-rs /
ledger_app artifacts from the matching builders.
Sjors added 2 commits April 29, 2026 18:39
New `coldcard_sim` job (depends on `linter`) that builds the
upstream `coldcard-mpy` simulator in a pinned `ubuntu:24.04`
container. The firmware tag and the three source patches that
make it build under GCC 15 + the libngu submodule fix live in
`hwi-rs/tests/coldcard-sim/`. The built tree is cached on
`tag + Dockerfile + patch + build.sh` hashes, so subsequent runs
at the same pin skip the multi-minute clone + compile, and the
`coldcard-mpy` binary + `simulator.py` are uploaded as a tarball
artifact for later integration jobs to consume.

Sim build inputs are committed under `hwi-rs/tests/coldcard-sim/`:
Dockerfile, README, `firmware-tag.txt`, the three patches, and
`build.sh` (idempotent, runtime selectable via `CONTAINER_RUNTIME`).

Introducing the simulator builder here — before any of the
per-command Coldcard backend commits — lets a reviewer (or a
follow-up CI job) spin up the simulator at any subsequent commit
to manually drive each new subcommand against it as it lands.
Add the Coldcard branch to `enumerate`, both the HID-attached path
and the `HWI_RS_COLDCARD_SIMULATOR=1` Unix-socket simulator path.

The HID path uses `coldcard::Api::from_borrowed` against the same
shared `HidApi` instance that the Ledger scan walks; the simulator
path goes through `Coldcard::open_simulator` (added in the previous
commit). Either way the JSON shape is the same as for Ledger:
`{kind, model, label, path, fingerprint, ...}`.

Detect the hardware variant (`mk4`, `q1`, simulator's `mk5`) by
parsing the last non-empty line of `Coldcard::version()`; falls
back to `coldcard` if the format ever changes.
Sjors added 9 commits April 29, 2026 18:39
Add a thin signer wrapper (`tests/coldcard-signer.sh`) that exports
`HWI_RS_COLDCARD_SIMULATOR=1` before exec`ing `hwi-rs`, and a
matching `tests/run-core-scenario-coldcard.sh` that launches the
`coldcard-mpy` simulator (natively when `COLDCARD_SIM_DIR` is set,
otherwise via Podman from `COLDCARD_SIM_IMAGE`) and probes it with
`hwi-rs enumerate`.

Wire it up as a `coldcard_integration` job in CI (downloads the
`coldcard_sim`, `bitcoind_builder`, and `hwi_rs_builder` artifacts;
runs the scenario natively on the GHA runner). Subsequent commits
will grow the script to exercise each new Coldcard subcommand as it
lands.
Adds the `getdescriptors --account <n>` subcommand. Builds the four
default Ledger Bitcoin app single-sig descriptor families
(BIP44/49/84/86) for both the receive and change branches, with BIP380
checksums applied via miniscript round-tripping.

Bitcoin Core invokes `getdescriptors` automatically when an external
signer wallet is created (`createwallet ... external_signer=true`), so
the mock and speculos integration scenarios are extended through
`createwallet` and `getnewaddress`.
Add the Coldcard branch to `getdescriptors` so a Coldcard (real or
`coldcard-mpy`-emulated) can populate the receive + change descriptors
that Bitcoin Core's external-signer wallet imports.

Same code path for both transports: walk `ADDR_TYPES`
(BIP44/49/84/86), pull each `xpub` via `Coldcard::xpub`, format with
`format_descriptor`. The HID branch falls through to the existing
Ledger scan if no Coldcard matches the requested fingerprint.
Adds the `displayaddress --desc <descriptor>` subcommand. Bitcoin Core
calls this from `walletdisplayaddress` after running InferDescriptor on
the scriptPubKey, so the descriptor is definite (no wildcards) and
expected to be echoed back as JSON `{"address": ...}` matching the
address Core asked for.

The new policy module classifies the inferred descriptor against the
four default Ledger Bitcoin app single-sig wallet templates
(BIP44/49/84/86), the device session is opened with the matching
default policy via `with_wallet(wallet_hmac=None)`, and the device is
asked to render the address. The mock device skips the on-device step
and just echoes the address. The mock integration scenario is extended
through `walletdisplayaddress`.
Add the Coldcard branch to `displayaddress`. Reuses the
single-sig descriptor classifier from `policy.rs` to recover
(purpose, account, change, index) from the descriptor at the
requested derivation, then issues `Coldcard::address` with the
matching `AddressFormat` (P2PKH/P2WPKH-P2SH/P2WPKH/P2TR for
BIP44/49/84/86 respectively).

We re-derive the address ourselves on the host and only use the
device's response as a confirmation signal, mirroring the Ledger
branch.
Adds the `signtx <psbt-base64>` subcommand and a global `--stdin`
flag. Bitcoin Core invokes signing as
    hwi-rs --stdin --fingerprint <fp> --chain <chain> signtx
and feeds the base64 PSBT (which can exceed argv limits) on stdin.
The reply is JSON `{"psbt": "<base64>"}` with the signer's
contribution merged in.

Mock signer uses the embedded BIP32 test vector 1 master and rust-
bitcoin's `Psbt::sign` to produce real ECDSA signatures, so the mock
integration scenario can drive Core all the way through
`walletprocesspsbt` and assert `complete: true`.

Ledger signer groups PSBT inputs by their (BIP44 purpose, account)
pair, builds the matching default Ledger Bitcoin app wallet policy,
attaches it via `with_wallet(wallet_hmac=None)`, and calls `sign_tx`.
Cross-account / cross-script PSBTs return an explicit error since the
new app needs one wallet policy session per `sign_psbt` call. The
speculos integration scenario funds the wallet, builds a real PSBT,
runs `walletprocesspsbt` against the live emulator, and uses
Speculos's HTTP automation API to auto-confirm prompts in the
background. It then asserts the PSBT is fully signed.
Add the Coldcard branch to `signtx`. The body lives in
`devices::coldcard::do_signtx`:

  1. Decode the base64 PSBT and verify at least one input has a
     BIP32 derivation under our fingerprint (otherwise the device
     would loop in `get_signed_tx`).
  2. Call `Coldcard::sign_psbt` with `SignMode::Signed`.
  3. Poll `get_signed_tx`. On the simulator we additionally inject a
     `y` keypress every poll via the `XKEY` USB test command; the
     `coldcard-mpy` build interprets it, the production firmware
     ignores it.
  4. Merge sigs back into the caller`s PSBT (preserving any extra
     PSBT fields the device may have stripped) and return base64.

Extend `tests/run-core-scenario-coldcard.sh` with the actual sign
step: fund the wallet, build a PSBT via `walletcreatefundedpsbt`,
and drive `walletprocesspsbt` to `complete:true`.
Mirrors HWI's getxpub: takes a BIP32 derivation path and returns
{"xpub": "<base58>"}. getdescriptors only covers the canonical
BIP44/49/84/86 paths, so for keys at custom paths (e.g. the BIP48
P2TR account key m/48h/1h/0h/2h needed for MuSig2 cosigners) callers
previously had to fall back to the upstream ledger_bitcoin Python
client. This subcommand closes that gap.
Add the Coldcard branch to `getxpub`. The body delegates to
`Coldcard::xpub(Some(path))`; no `display_xpub`-style flag is needed
because the Coldcard firmware always confirms custom-path xpub
exports on-screen.
@Sjors
Copy link
Copy Markdown
Owner Author

Sjors commented Apr 29, 2026

Improved ColdCard simulator caching.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant