A proof that bhwi's sans-io design actually delivers on its promise: a Zig CLI talks to hardware wallets while owning all of the IO, and reuses only the pure protocol logic from Rust through a small C FFI: one device-generic API (bhwi's common interpreter) for BitBox02, Ledger and Jade.
This matters because Zig has its own opinionated concept of IO: since 0.15,
std.Io is an explicit interface passed as a parameter,
letting the application choose the implementation (blocking, thread pool,
io_uring, green threads) without the library caring. A protocol library that
did its own IO, or dragged in a foreign async runtime, would fight that
model. Because bhwi is sans-io, this CLI plugs the interpreters straight into
Zig's native std.Io handling (TCP connections, file access and device IO all
go through the Io instance provided to main), and the same would hold for
any other host language with its own IO story.
The claim is demonstrated. This CLI runs end-to-end against the official
BitBox02 firmware simulator: it pairs (noise XX handshake, pairing code shown,
config persisted so re-pairing is silent), seeds the device, and returns the
master fingerprint 4c00739d and an xpub at m/84'/0'/0' that match, byte for
byte, the values derived host-side from the simulator's known seed, with every
wire byte moved by Zig and every protocol byte produced by Rust. The same C API
and the same Zig driving loop also drive a Ledger (speculos emulator or real
hardware) and a Jade (QEMU emulator or real hardware over serial); only the
wire framing underneath differs.
Zig (owns all IO) │ Rust (pure, no IO)
│
main.zig ──── driving loop (driver.zig) │ bhwi-ffi: one C API
│ │ │
Link (per-device framing) │ bhwi::common::{Command,
┌──────────────┼───────────────┐ │ Transmit, Response, Error}
BitBox02 Ledger Jade │ │
hww.zig HWW ledger_hid.zig jade.zig │ BitBoxInterpreter (noise XX,
u2f.zig U2F or speculos CBOR-RPC, │ pairing, protobuf)
frames APDUs pinserver │ LedgerInterpreter (APDUs,
│ │ HTTP leg │ Merkle client commands)
hidraw.zig / transport.zig (USB, TCP, │ JadeInterpreter (CBOR-RPC,
serial) │ PIN-server auth)
│
(payload bytes, recipient, encrypted) ⇄ reply bytes
The only thing crossing the boundary is bytes: the interpreter says "send this payload" (and to whom: the device, or, during Jade auth, Blockstream's PIN server), the Zig side moves it over whatever wire it wants, and feeds the reply back. The BitBox noise handshake, the Ledger Merkle/client-command protocol, the Jade CBOR-RPC encoding and the multi-round state machines all run unchanged in Rust; the U2F and Ledger-HID framings, the HWW retry protocol, CBOR message boundary detection, the PIN-server HTTP POST, device discovery and pairing persistence are all Zig. No async runtime anywhere: the interpreters are synchronous and IO-free.
ffi/: Rust crate wrapping bhwi's common interpreter surface as astaticlib.include/bhwi.his the normative C contract (ownership, error codes, the noise-outlives-interpreter rule). Per-device constructors (bhwi_interp_new_bitbox/bhwi_interp_new_ledger/bhwi_interp_new_jade), everything else is device-generic.cli/: the Zig program.src/ffi.zigmirrors the header; everything else is IO owned by Zig (link.zigis the per-device framing seam).
nix develop # zig, cargo/rustc, gcc
make cli # cargo build --release (ffi) + zig build
make test # U2F + Ledger-HID + CBOR framing unit testsnix run .#bitbox starts the pinned simulator (listens on 127.0.0.1:15423,
auto-confirms pairing). Then, in another terminal:
make e2e
# or by hand:
./cli/zig-out/bin/bhwi-zig --simulator seed # idempotent; prints the pairing code on first pair
./cli/zig-out/bin/bhwi-zig --simulator fingerprint # 4c00739d
./cli/zig-out/bin/bhwi-zig --simulator xpub "m/84'/0'/0'"
# xpub6BrUqPwnpwg7jpetDNH4r2y7Y2ffL1P6qkVAGE87Z3nL3frMLj2ZifEQCHbRPQzYG9Lkyb2syWJFBuoR7c63WdzGvEQRNSgvKDZRBXb1pBQ
./cli/zig-out/bin/bhwi-zig --simulator version # v9.26.1 BitBox IHAR initialized=1nix run .#ledger starts speculos with the Ledger Bitcoin app (re-exported
from bhwi's flake, which builds both from source; expect a long first run).
APDU port 127.0.0.1:9999. Then, in another terminal:
make e2e-ledger
# or by hand (the speculos app is testnet):
./cli/zig-out/bin/bhwi-zig --speculos fingerprint # f5acc2fd
./cli/zig-out/bin/bhwi-zig --speculos xpub "m/44'/1'/0'"
# tpubDCwYjpDhUdPGP5rS3wgNg13mTrrjBuG8V9VpWbyptX6TRPbNoZVXsoVUSkCjmQ8jJycjuDKBb9eataSymXakTTaGifxR6kmVsfFehH1ZgJT
./cli/zig-out/bin/bhwi-zig --speculos version # 2.4.1 Bitcoin TestNo pairing, no unlock: the Ledger interpreter is stateless; the app just has to be open on the device.
nix run .#jade starts the Jade firmware in QEMU (re-exported from bhwi's
flake, which builds the ESP32 firmware from source; expect a long first run).
CBOR-RPC port 127.0.0.1:30121. Seed it once with nix run .#jade-init (sets
the fixed test mnemonic and points the device at a local pinserver). Then:
make e2e-jade
# or by hand:
./cli/zig-out/bin/bhwi-zig --jade --network testnet fingerprint # e3ebcc79
./cli/zig-out/bin/bhwi-zig --jade --network testnet xpub "m/44'/1'/0'"
# tpubDCKD5cdxMEFd2i4cNa3PJUbUHMsGDxsnfqjxVpMoG1ymWYUQUaZzTcHQo3JwYgaKe2FyKGA2FzGPSVczBoAiHGyERuA1mZ2UkGKufEnUxKk
./cli/zig-out/bin/bhwi-zig --jade --network testnet version # 1.0.39-...Every command starts with the auth flow. On the freshly initialized emulator
the device reports itself unlocked and auth completes in one round trip; on a
locked Jade the interpreter routes one payload to the PIN server instead of
the device, and the CLI carries it as an HTTP POST (nix run .#jade-pinserver
runs a local one on port 8096 for the emulator). That HTTP leg is ordinary
caller-owned IO, same as the device wire: sans-io means the interpreter only
ever says "deliver these bytes to this recipient".
./cli/zig-out/bin/bhwi-zig fingerprint # auto-scans /sys/class/hidraw for a BitBox02 or Ledger
./cli/zig-out/bin/bhwi-zig --hidraw /dev/hidraw3 fingerprint
./cli/zig-out/bin/bhwi-zig --serial /dev/ttyACM0 fingerprint # Jade (USB CDC serial, not HID)Discovery matches a BitBox02 (VID 03eb, vendor usage page 0xffff) or a Ledger
(VID 2c97, usage page 0xffa0) and picks the wallet interface by report
descriptor. A Jade enumerates as a serial port instead; the CLI opens it raw
at 115200 with DTR/RTS cleared (setting them reboots the device). On first
BitBox pair the CLI prints the pairing code to confirm on the device, then
persists the noise config to $XDG_CONFIG_HOME/bhwi-zig-example/noise.conf so
later sessions skip the confirmation. Accessing /dev/hidrawN may need udev
rules such as:
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="03eb", ATTRS{idProduct}=="2403", TAG+="uaccess"
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="2c97", TAG+="uaccess"
Single-threaded. The noise handle (BitBox-only) must outlive any BitBox
interpreter created from it and must not be touched while one is alive (except
polling the pairing code). bhwi_interp_start consumes the command,
bhwi_interp_end consumes the interpreter, both even on error. Buffers
returned via out-pointers are freed with bhwi_bytes_free /
bhwi_string_free. Status codes: 0 ok, -5 authentication refused (pairing
rejected / aborted on device), other negatives are host errors with a message
in bhwi_last_error(). Ledger replies must keep their trailing SW1SW2 status
word; the interpreter parses it. Jade replies are one complete CBOR message,
and a payload whose *out_pinserver_url is set travels as an HTTP POST to
that URL instead of to the device. If linking the staticlib fails in your
environment, switch cli/build.zig to link ffi/target/release/libbhwi_ffi.so
instead (the cdylib is also built).