hwi-rs: a drop in replacement for HWI#1
Open
Sjors wants to merge 16 commits into
Open
Conversation
2061db1 to
f12cb47
Compare
This was referenced Apr 28, 2026
Open
Owner
Author
|
Rebased after wizardsardine#129 |
3b60fef to
3d6b0d7
Compare
Owner
Author
|
Rebased after wizardsardine#128. |
Owner
Author
|
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. |
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.
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.
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.
Owner
Author
|
Improved ColdCard simulator caching. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
enumerategetdescriptors --account <n>{"receive": [...], "internal": [...]}(BIP44/49/84/86).getxpub <path>{"xpub": "..."}for a custom BIP32 path (e.g.m/48'/1'/0'/2').displayaddress --desc <descriptor>{"address": "..."}.signtx <base64-psbt>{"psbt": "..."}. Use--stdinfor large PSBTs.Supported devices
Build
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-qtto invoke this binary as the external signer: