Skip to content

feat: Blockstream Jade hardware wallet support - #152

Closed
coreyphillips wants to merge 2 commits into
masterfrom
feat/jade-via-crate
Closed

feat: Blockstream Jade hardware wallet support#152
coreyphillips wants to merge 2 commits into
masterfrom
feat/jade-via-crate

Conversation

@coreyphillips

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 not yet been exercised against physical hardware. 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. It should move to a version before this merges, or 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

Not yet run against a physical Jade. That is the main thing this draft is waiting on. Two constants need confirming on hardware: MIN_JADE_FIRMWARE, and the m/0' parent-fingerprint route used for the master fingerprint.

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, 54 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; the two second inter-chunk deadline and the write-with-response requirement are the parts that fail silently if missed.

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 deleted the feat/jade-via-crate branch September 3, 2026 18:30
@coreyphillips

Copy link
Copy Markdown
Collaborator Author

Superseded by #153. The branch was renamed from feat/jade-via-crate to feat/jade-hardware-wallet, which closed this PR rather than retargeting it. Same commits, same diff.

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