Skip to content

feat: Blockstream Jade hardware wallet support - #153

Draft
coreyphillips wants to merge 3 commits into
masterfrom
feat/jade-hardware-wallet
Draft

feat: Blockstream Jade hardware wallet support#153
coreyphillips wants to merge 3 commits into
masterfrom
feat/jade-hardware-wallet

Conversation

@coreyphillips

@coreyphillips coreyphillips commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Adds Blockstream Jade as a third hardware wallet, alongside Trezor and Foundation Passport.

The Jade protocol lives in a new crate, jade-client-rs, and this PR carries the UniFFI adapter that exposes it to the apps. There is no existing Rust client for Jade, so the CBOR protocol was implemented from the firmware and jadepy sources.

Draft: the protocol has now been exercised against a physical Jade v1, over both
USB serial and Bluetooth. Still draft for bindings regeneration, a version bump,
and the git pin. See QA Notes.

Description

Scope is Bitcoin single signature, matching what Bitkit does today. Liquid, multisig, firmware updates and the airgapped QR mode are out of scope.

FFI surface (18 functions): scan, connect, disconnect, cancel, ping, unlock, logout, version info, xpub, account export, master fingerprint, address verification, message signing, PSBT signing, plus the transport callback setter.

Jade returns a signed PSBT, so it follows the Passport route rather than the Trezor one:

onchain_compose_transaction -> jade_sign_psbt -> finalize_psbt -> onchain_broadcast_raw_tx

Transports. Bluetooth on every platform, driven by the app through a JadeTransportCallback in the same shape as TrezorTransportCallback. USB CDC serial additionally on desktop and Python, driven from Rust.

Why a separate crate. The protocol is useful outside Bitkit, iterating on it does not need a bitkit-core release, and it keeps ciborium, serde_bytes and serialport out of this repo's direct dependencies. The split follows the trezor-connect-rs precedent, with two improvements worth noting for review:

  • Types use #[uniffi::remote] rather than mirrored structs. That generates the same scaffolding a derive would, against types defined in another crate, so there are no parallel definitions and no hand-written From conversions in either direction. The trezor module carries roughly 900 lines of exactly that; the adapter here is 969 lines total.
  • Transport failures cross the boundary as a typed JadeTransportErrorCode. The trezor adapter has to encode its code into a sentinel string and parse it back out, because its upstream crate offers no typed channel.

Things a reviewer should look at deliberately:

  • The dependency is a git rev, not a crates.io version. Deliberate, so this never depends on an unpublished release. Pinned at d55fafb. It moves to a published version once the crate is on crates.io; until then the merge should accept the git pin knowingly.
  • HardwareWalletVendor::Blockstream is a new enum case. That makes exhaustive Kotlin when and Swift switch over the vendor enum non-exhaustive, which is source breaking for consuming apps. Appended after Foundation, since UniFFI assigns discriminants by declaration order.
  • Bindings are not regenerated in this PR, and there is no version bump.
  • No u8 or u16 in the FFI surface. ping returns an enum and battery_status is u32, keeping this module off the narrow unsigned return path that 0.5.14 fixed for Android ARM32.

Three protocol details produce code that compiles and then fails only against hardware. Each is verified against firmware source and covered by a test in the crate:

  • Binary fields must be CBOR byte strings. serde encodes a plain Vec<u8> as an array of integers, which the device rejects for psbt and entropy.
  • Replies with id "00" are terminal errors, not stray frames. Jade uses that id when it rejects a message before recovering the real one, so discarding them turns every such rejection into a full length timeout.
  • An HTTP failure during unlock must still send pin with no params, or the device stays blocked and consumes the next unrelated request as the awaited reply.

Preview

Not applicable; this is FFI surface with no UI. src/modules/jade/README.md documents the architecture, and the crate's README documents the wire protocol and the Bluetooth contract a native transport must honour.

QA Notes

Run against a physical Jade v1, firmware 1.0.41, from macOS, over both transports. Everything below passed.

Serial Bluetooth
Connect, version info, ping yes yes
Unlock through the pinserver yes yes
Account export yes yes
Address verification, wpkh and tr yes wpkh
Message signing yes yes
PSBT signing yes yes
Fragmented reply via get_extended_data not reached yes
Cancellation, logout yes not reached

Signatures were checked rather than assumed: each PSBT signature verifies against its own p2wpkh sighash under the key the account xpub derives, and the message signature recovers to that same key. Every PSBT spent a fabricated prevout, so nothing was broadcastable.

The 20 input PSBT is the interesting one. Its 2921 byte request went out as six Bluetooth writes, five at the full 509 byte MAX_CHUNK_BYTES, and the 5005 byte reply came back in two fragments reassembled through get_extended_data. That path had no hardware coverage before.

Of the two constants this draft was waiting to confirm:

  • The m/0' parent-fingerprint route is confirmed. Not merely self-consistently: the device signed a PSBT whose BIP32 origins carried the fingerprint that route produced, 54f40d7d. A wrong fingerprint means the device matches none of its own keys and signs nothing.
  • MIN_JADE_FIRMWARE is still unconfirmed. Only 1.0.41 has been exercised. 0.1.48 remains a reasoned guess from the firmware sources, and the device stays the authority: it answers UNKNOWN_METHOD for anything it cannot do.

Two things found and fixed in the crate while doing this, both in the transport layer, neither reachable by the scripted mock. The pin now sits at d55fafb, which carries both:

  • SerialTransport cleared DTR and RTS unconditionally. Correct for /dev/tty*, inverted for the macOS call-out node, where the device answered 0 of 9 requests with the lines cleared and 9 of 9 with them asserted. close cleared them too, so every run left the device unresponsive until it was physically power cycled.
  • exchange applied its timeout only to the reply wait, leaving write_all unbounded, so a transport whose write stalls hung forever and the caller's timeout never fired. This one matters directly for this adapter, because JadeTransportCallback writes are driven by the application: a Bluetooth write with response that is never acknowledged used to hang a signing call with no timeout and no error.

Untested and worth stating plainly: Jade Plus, Linux serial, and the /dev/tty* half of the DTR and RTS rule, which rests on jadepy's behaviour rather than on measurement.

A caveat on the QA script below: it uses Testnet, and a device configured JADE_NETWORKS: MAIN rejects that with NetworkMismatch. Use Mainnet paths on a mainnet-only unit, or reconfigure the device.

Automated:

cargo clippy --all-targets -- -D warnings
cargo test modules::jade              # 6 adapter tests
cargo test modules::hardware_wallet   # catalog, count bumped 6 -> 8
cargo test -- --skip modules::blocktank

473 pass, 0 fail. Blocktank is skipped because its tests reach api.stag.blocktank.to and fail on any machine without access to it; that is pre-existing on master.

Protocol level coverage lives in the crate (cargo test there, 57 tests against a scripted mock device and a fake pinserver, no hardware or network needed).

Platform gating, which matters because serialport must never reach a mobile build:

cargo check --target aarch64-apple-ios      # serialport absent
cargo check --target aarch64-linux-android  # serialport absent; needs NDK clang on PATH

Hardware path once a device is available, over USB so no app is required:

./build_python.sh

then scan, connect, jade_unlock(Testnet) entering the PIN on device, master fingerprint returns 8 hex chars, jade_get_xpub("m/84'/1'/0'") returns a tpub, jade_verify_address matches a host-derived address, jade_sign_message verifies against the returned address, and onchain_compose_transaction to jade_sign_psbt to finalize_psbt yields a stable txid. Broadcast on regtest or testnet only.

Bluetooth end to end needs an app-side JadeTransportCallback. The contract is documented on the trait, and the crate now carries reference material for whoever writes the Swift and Kotlin sides:

  • examples/callback_transport.rs is the same shape as JadeTransportCallback, with the platform owning the radio and Rust only moving bytes. It runs against a scripted link, so cargo run --example callback_transport exercises the whole path with no hardware.
  • docs/bluetooth.md covers the service UUIDs, chunk sizing per platform, and the rules that fail silently when missed.

The write-with-response requirement and the two second inter-chunk deadline are still the parts that fail silently. Two more are worth passing to the app teams: notifications must be handed over untouched and in order, since frames are not aligned to notifications, and the link must be disconnected on the way out, because a Jade whose central vanished can refuse new connections until it is physically power cycled.

Adds a `jade` vendor adapter covering Bitcoin single signature use:
discovery, connect, PIN unlock through the blind pinserver, extended
public key and account export, on-device address verification, message
signing and PSBT signing. Transports are Bluetooth on every platform,
through a native `JadeTransportCallback`, plus USB CDC serial on desktop
and Python builds.

There is no Rust crate for Jade, so the CBOR protocol is implemented
here. Signed PSBTs feed the existing `finalize_psbt` path, the same route
Passport already uses.

Details worth calling out, each verified against Jade firmware:

- Binary fields carry `#[serde(with = "serde_bytes")]`. serde encodes a
  plain `Vec<u8>` as a CBOR array, and Jade reads `psbt` and `entropy`
  with `rpc_get_bytes_ptr`, which requires a byte string. A test asserts
  the encoded header byte, because this fails only against hardware.
- Replies with id "00" are treated as terminal errors for the request in
  flight. Jade uses that id when it rejects a message before recovering
  the real one, so discarding them would turn every such rejection into
  a full length timeout.
- An HTTP failure during unlock still sends `pin` with no params. The
  device blocks indefinitely waiting for one, so abandoning the exchange
  would leave it consuming the next unrelated request as the awaited
  reply.
- Framing reports malformed input rather than returning a truncated
  frame, caps the read buffer, and poisons the connection on any error,
  since there is no way to find the next boundary in a corrupt stream.
- Session state is kept out of the I/O lock so `jade_cancel` and
  `jade_disconnect` return promptly while a five minute confirmation is
  pending. Jade has no cancel message, so closing the link is the only
  abort mechanism.
- Path validation is stricter than `DerivationPath::from_str`, which
  accepts "" as the master path and accepts a path with no `m/` prefix.
- Pinserver requests are constrained to https, port 443, no redirects,
  no onion hosts and a resolved public address, because the URL list
  comes from the device.
- No `u8` or `u16` in the FFI surface, keeping this module clear of the
  narrow unsigned return path that needed a generator fix for ARM32.

Tests run against a scripted mock device and a fake pinserver, so no
hardware or network access is required.
The Jade protocol, pinserver exchange, PSBT checks and serial transport
now live in https://github.com/coreyphillips/jade-client-rs. What stays
here is the FFI adapter: the transport contract the native application
implements, the session lock a free-function FFI surface implies, and
UniFFI scaffolding for the crate's types.

The module drops from roughly 4,400 lines to 969, and bitkit-core no
longer depends on ciborium, serde_bytes or serialport directly.

Types are declared with `#[uniffi::remote]` rather than mirrored. That
generates the same scaffolding a derive would, against types defined in
another crate, so there is no parallel set of structs and no hand-written
From conversions in either direction. For comparison, the trezor module
carries about 900 lines of exactly that against trezor-connect-rs.
`#[uniffi::remote(Error)]` has to match every variant, which is why the
crate's JadeError is deliberately not non_exhaustive.

Two behavioural improvements come with the split:

- Transport failures cross the boundary as a typed JadeTransportErrorCode
  rather than a sentinel string. The trezor adapter has to encode its
  code into text and parse it back out, because its upstream crate offers
  no typed channel; owning both sides here avoided that.
- The crate's Jade takes &mut self per operation, so the one request at a
  time rule the firmware enforces is a compile time property. Aborting
  goes through a CancelHandle that works while an operation holds the
  borrow, and it stays outside the session lock so disconnect and status
  reads never queue behind a five minute confirmation.

The FFI functions now take plain arguments instead of parameter records,
matching the crate. This surface has not shipped, so nothing depends on
the old shape.

The dependency is pinned by git revision until the crate is published, so
this never depends on an unreleased version.

Protocol tests moved to the crate, where 54 of them run against a
scripted mock device and a fake pinserver. The 6 left here cover the
adapter: the account type mapping and the callback bridge, including
chunk size clamping and typed error propagation.
@coreyphillips coreyphillips self-assigned this Sep 3, 2026
@coreyphillips coreyphillips added the enhancement New feature or request label Sep 3, 2026
Picks up two transport fixes found while running the crate against a
physical Jade v1 on firmware 1.0.41, neither of which the scripted mock
could reach.

SerialTransport cleared DTR and RTS unconditionally on open and on
close. That is right for /dev/tty*, where the kernel asserts them on open
and the transition reboots the ESP32, and wrong for the macOS call-out
node: on /dev/cu.usbserial-* the device answered 0 of 9 requests with the
lines cleared and 9 of 9 with them asserted, and because close cleared
them too, every run left the device unresponsive until it was physically
power cycled. jadepy keys off the same path prefix.

JadeConnection::exchange took a timeout and applied it only to the reply
wait, leaving write_all unbounded. A transport whose write stalls
therefore hung forever and the caller's timeout never fired. Serial could
not show this, since its writes go through spawn_blocking to a port with
its own timeout; a Bluetooth write with response can, and did. That
matters directly for this adapter, because JadeTransportCallback writes
are driven by the application.

Also drops the macOS /dev/tty.* dial-in twin from enumeration, which
listed one device twice and offered a path that blocks on open.

Cargo.lock carries only the source rev: the crate's own dependency set is
unchanged between the two revisions.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant