feat: Blockstream Jade hardware wallet support - #153
Conversation
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.
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.
A Bluetooth address stays valid across scans, so requiring the path to appear in the last scan made a silent reconnect impossible: a device that had stopped advertising, or that was reconnected before the next scan, was unreachable until a scan happened to see it again. An unknown path is now accepted and handed to the native transport, which reports it as unreachable if it cannot be opened. Android bindings are regenerated to pick up the Jade surface and this change.
- Update jade-client-rs repo - Bump version to 0.5.16
- Update jade-client-rs repo - Bump version to 0.5.16 - Updated bindings
There was a problem hiding this comment.
Reviewed 27 files: adds Blockstream Jade hardware wallet support as a UniFFI adapter over the jade-client-rs crate, with device discovery, a serialized connection lifecycle, and signing exposed through 20 new jade_* exports. 3 non-blocking findings inline, 1 note below.
Security
Read keys, crypto, payments, auth, network, storage, permissions, logging. No finding met the bar.
Notes
- The repo's PR convention (
.github/pull_request_template.md) asks for a description that lets a reviewer separate deliberate non-goals from omissions, and this one does not say what the change leaves out. Multisig, Liquid, and firmware update are all plausible Jade capabilities a reader might expect. Could we add a short out-of-scope section naming the Jade capabilities this PR deliberately does not cover?
Coverage
Total: 45%
- Journeys: 70% - The eight tests in jade/lifecycle_tests.rs exercise connect, disconnect and cancel; signing, account export and idle disconnect are covered only by the QA Notes
- Unit tests: 65% - jade/tests.rs and jade/lifecycle_tests.rs cover the account-type mapping, transport bridge and lifecycle; sign_psbt, account_export and notify_disconnected have no test
- QA: 0% - Manual Tests not run
Reviewed by Claude Code (claude-opus-5-xhigh) via gh-pr-review-loop skill
jvsena42
left a comment
There was a problem hiding this comment.
Reviewed this as a funds-and-key change: PSBT signing, xpub and account export, address verification, and a CBOR parse boundary fed by the device. Read the adapter and jade-client-rs at the pinned rev d52ccd9 in full.
No blocking findings. Five LOW notes inline, none of which need to hold the PR.
Fund safety is the part I most wanted to break, and it holds. sign_psbt never trusts the device's reply: verify_signed_psbt (client.rs:568-617) compares unsigned_tx — so every output, amount and input outpoint — plus input/output counts, and witness_utxo / non_witness_utxo txid when echoed, and requires at least one new signature. The adapter (implementation.rs:406-423) only decodes, deserializes and re-encodes that verified result, and finalize_psbt re-checks unsigned_tx and prevouts again. The one gap is the sighash flag on a returned taproot signature, noted inline and pre-existing.
Checked and clean
- Key material. No xpub, fingerprint, derivation path, PIN or PSBT bytes reach a log. Adapter logging is three statements, two of them constant text. Crate logs carry method names, ids and byte counts, and reqwest errors go through
without_url().#![deny(clippy::print_stdout, print_stderr, dbg_macro)]. Nothing written to disk. - Pinserver. The URL is device-supplied and validated: https only, no credentials, port 443 only, no
.onion, DNS resolved and pinned to a single public address with private/CGNAT/link-local/v4-mapped rejected, redirects disabled, body capped at 64 KiB, at most 4 round trips,on-replymust bepin. Nodanger_accept_invalid_certsand no http fallback anywhere in the crate. A non-default host only warns rather than failing, which is deliberate and documented; the host is a courier for a device-encrypted blob either way. - Protocol bounds. Frames capped at 64 KiB, reassembly capped at 64 fragments and 64 KiB with
checked_add, fragment sequence echoed and checked, absolute deadline, buffer cleared on malformed CBOR, connection poisoned on violation. Derivation paths capped at depth 8. Nounwrap/expect/panic!in crate or adapter source. - Address verification. A purpose/variant mismatch is rejected and the device's returned address is compared against the expected one, so a wrong path cannot report success unless the host derived the expectation from that same wrong path.
- Lock discipline.
connectcloses any prior session first, checks the watch before and after the native open, and cancels the handshake viaselect. The cancel handle lives outside the session lock, sodisconnectreturns promptly during an in-flight operation. Lock order is consistent betweenconnectanddisconnect_session. Separate manager and callback statics per vendor, with no shared fingerprint, xpub or session cache. - Your flagged items all hold.
Blockstreamis last in the Rust enum and last in both generated bindings, withTrezor=1 andFoundation=2 unchanged. Nou8/u16in any of the 20jade_*exports.0.5.16is consistent acrossCargo.toml,Package.swift,gradle.propertiesandsetup.py, and thePackage.swiftchecksum matches the checked-in xcframework zip.Cargo.lockpinsd52ccd9099f28aa75fc30f4b94315c2afb64321c.cargo treeforaarch64-linux-androidandaarch64-apple-iosshows noserialport.
One note on the release, not the code
The description presents the new HardwareWalletVendor case as source-breaking for consuming apps, which reads as upcoming. It already shipped. v0.5.15 (2026-09-07) and v0.5.16 (2026-09-15, currently Latest) are both published releases whose tagged commits carry src/modules/jade and are ancestors of this branch but not of master. Consumers therefore already have the new case, and the current Latest release contains code that is not on master. Verified with git cat-file -p "<tag>^{commit}:<path>", since git show <tag>:<path> misreports on these annotated tags.
Consumer side: synonymdev/bitkit-android#1231 is the app-side PR for this FFI; there is no iOS counterpart open.
Not verified
I did not execute the test suite — a protoc dependency in the trezor-connect-rs build script and then disk pressure stopped two attempts. gh pr checks 153 reports no checks at all on this head, so as far as I can see nothing has run cargo test against it. The checked-in iOS and Python binaries cannot be inspected; the checksum match proves Package.swift references that zip, not that the zip was built from this source.
- Reject signatures that do not commit to the whole transaction in finalize_psbt, covering taproot key spends and already finalized inputs - Stop reading and writing a callback transport once it is closed, so a disconnect during a handshake returns promptly - Return NotInitialized from jade_scan on mobile when no transport callback is registered - Serve jade_get_version_info from a copy so it does not wait behind an operation in flight - Stop jade_notify_disconnected from cancelling a queued reconnect, and document the ordering it expects - Add adapter tests for malformed PSBTs, native disconnects, and the lifecycle fixes - Skip the debug symbols upload when a manual publish has no matching release - Drop the stale release preparation section from the jade README
There was a problem hiding this comment.
Verdict: ✅ Approve
Reaudit: diff 7 files.
Findings:
2 inline (non-blocking)
Coverage:
Journeys: 85% - The delta adds disconnect-during-handshake, native disconnect path matching and version info under load to the lifecycle journeys; signing on a device is still QA only
Unit tests: 85% - Four new lifecycle tests and three new psbt.rs tests cover every production path this delta changed, including both sighash rejection cases
Reviewed by claude-opus-5-xhigh via gh-pr-review-loop skill
Commands: @ovi-reviewer test · retest · audit (author or owner)
| TAG: ${{ github.event.release.tag_name || inputs.version }} | ||
| shell: bash | ||
| run: | | ||
| # A manual publish may target a version with no GitHub release; skip the |
There was a problem hiding this comment.
The guard sends all of gh release view stderr to /dev/null and branches on exit status alone, but a non-zero exit also covers auth failure, a network error and rate limiting, not just a missing release. Any of those takes the else branch and logs No release for $TAG; skipping, so a genuine release publishes without its native debug symbols and nothing in the run signals it. Could we branch on the error text, or let an exit that is not the missing-release case fail the step?
There was a problem hiding this comment.
Fixed in a8e0b99. A release event now uploads unconditionally, since a release definitionally exists there and any failure should fail the step. A workflow_dispatch still does the lookup, but it captures stderr and only takes the skip branch when the message says the release was not found; anything else (auth, network, rate limiting) prints the error and exits 1, so a publish without symbols can no longer pass silently.
There was a problem hiding this comment.
Fixed: release events upload directly; manual dispatch skips only when the lookup reports a missing release, and every other lookup failure exits 1.
| } | ||
|
|
||
| async fn read_some(&self, timeout: Duration) -> Result<Vec<u8>, JadeError> { | ||
| self.ensure_open()?; |
There was a problem hiding this comment.
ensure_open() runs before spawn_blocking, and spawn_blocking cannot be cancelled, so a read_chunk already executing when close sets the flag runs to its own timeout; only the next call is stopped. write_all re-checks the flag between chunks at lines 202-204, but read_some issues one callback call and has no equivalent inner check. What bounds a disconnect during a handshake is therefore the native layer honouring timeout_ms, documented at lines 73-75 but not enforceable here. The new test passes because its mock returns immediately. Could we note in the trait docs that a read_chunk already in flight still runs to its timeout_ms?
There was a problem hiding this comment.
Fixed in a8e0b99, docs plus a small code change.
The trait docs now say that a read_chunk already inside the native layer cannot be interrupted, so requirement 3 is what bounds a disconnect issued mid-handshake. Requirement 3 also names the number now: read_some is called with at most READ_CHUNK_TIMEOUT_MS (250ms) from the crate's read loop, so an implementation that honours timeout_ms delays a disconnect by that much at worst.
On the code side, read_some re-checks the closed flag after the callback returns and yields Disconnected instead of the data, so bytes that arrive for a released path are dropped rather than fed back to the parser. bytes_arriving_after_close_are_discarded parks a mock inside read_chunk, closes while it is parked, then releases it; it fails without the post-call check.
There was a problem hiding this comment.
Fixed: the transport contract defines timeout_ms as the in-flight read bound, the post-callback guard discards bytes after close, and the focused test covers the race.
|
Is this PR also integrating the Jade simulator/ emulator? Or it is more HTTP-based, thus a better fit for |
|
No, this PR doesn't integrate the emulator, and the emulator isn't HTTP either. Blockstream's jade-emulator is a QEMU ESP32 image that speaks the same CBOR byte-stream protocol as the real device, exposed over a raw TCP socket ( The only HTTP in the Jade path is the pinserver, and that's Blockstream's hosted blind PIN oracle reached over HTTPS from If we do want emulator coverage later, the split would be:
Worth noting it wouldn't cover much of what's risky in this PR: the emulator is serial/TCP only, so the BLE contract (the 2s inter-chunk deadline, the write-with-response requirement) still only fails against real hardware. Today the adapter is covered by On Passport: our support for it is UR/QR only ( |
Fail the debug symbols upload on a lookup error instead of skipping it. The guard sent all of `gh release view` stderr to /dev/null and branched on exit status alone, so an auth failure, a network error or rate limiting took the "no release" branch and published without symbols. A release event now uploads unconditionally, and a manual dispatch skips only when the lookup says the release was not found. Discard bytes that arrive for a path after `close`. `close` cannot interrupt a `read_chunk` that has already entered the native layer, so `read_some` re-checks the closed flag once the callback returns rather than feeding stale bytes back to the parser. The trait docs now state that an in-flight read runs to its own `timeout_ms`, which is what bounds a disconnect issued mid-handshake.
|
@coreyphillips conflicts |
All conflicts were modify/delete on generated binding artifacts: this branch regenerated them for 0.5.16, and #154 stopped tracking them and added them to .gitignore. Took master's deletion in every case, so the generated Kotlin, the xcframework and the Python package are no longer checked in. CI regenerates them through the new bindgen workflows.
Pre-existing drift that made cargo fmt --check fail. No behaviour change.
|
Conflicts resolved in 76eebaf, Every conflict was a modify/delete on generated binding artifacts: this branch regenerated them for 0.5.16, and #154 stopped tracking them and added them to Nothing in Checked against what Bindgen Validation runs: |
jvsena42
left a comment
There was a problem hiding this comment.
Re-reviewed the delta since 43c3ffd: 74efa46, a8e0b99, the master merge 76eebaf and the rustfmt commit 9a53bf3. I read it with jade-client-rs at d52ccd9, miniscript 12.3.7 and the consumer side on synonymdev/bitkit-android#1231.
No blocking findings. One LOW note inline, on a doc promise the crate does not keep.
Earlier threads, checked at head
- Sighash flags in
finalize_psbt: holds.validate_signature_hash_typesruns afterinterpreter_checkover every input and readsfinal_script_sig/final_script_witness, so inputs a signer returns already finalized are covered too. Signatures are 64-byte (Default) or 65-byte with0x01, and both are accepted. Key-spend, P2PKH/P2WPKH/P2SH-P2WPKH and script paths includingmulti_aall produce a signature constraint, so none is skipped. The walk re-evaluates the same satisfactioninterpreter_checkalready verified, so it cannot newly reject a PSBT that Passport or Jade legitimately signs. send_replaceinnotify_disconnected: holds. It is gone, andjade_notify_disconnecteddocuments the await-before-reconnect rule.- Disconnect during the handshake: holds.
closedbelongs to eachCallbackTransport, andbuild_transportcreates a fresh one on every connect, so reconnecting to the same path cannot inherit a stale flag. jade_scanon mobile without a callback: holds. It returnsNotInitializedwithout touching the previous device list.jade_get_version_info: holds. It reads only theRwLock.- Bot threads (workflow guard, README, adapter tests, in-flight
read_chunkdocs): all hold.
Also clean
- The remerge diff of 76eebaf is exactly the eight generated-artifact deletions. Nothing under
src/,Cargo.*orPackage.swiftcame from conflict resolution. - The delta adds no logging of PSBT bytes, xpubs, paths or version details.
- No FFI signature changed between the
v0.5.16tag and head. The only semantic changes (jade_scan,jade_get_version_info,finalize_psbt) all favour the consumer. finalize_psbthas no caller on bitkit-android or bitkit-iosmasteryet. The stricter sighash check first reaches users with the Jade app PR.
Not verified
I did not run cargo test because protoc is missing locally. CI's Android Bindgen and iOS Bindgen were still pending on this head when I checked.
| /// Close the device and clear session state. | ||
| /// | ||
| /// Safe to call while an operation is waiting on a confirmation: the pending | ||
| /// request returns `UserCancelled` promptly rather than running out its deadline. |
There was a problem hiding this comment.
Most of the time, a disconnect during a confirmation wait returns DeviceDisconnected, not the UserCancelled this doc promises.
disconnect_session → CancelHandle::cancel sets aborted, then closes the transport. The crate's reply loop (jade-client-rs transport.rs:266-269) checks aborted only at the top of each iteration. The read arm (:278-283) poisons and returns the transport error as-is. Each iteration spends up to 250ms in read_some and 25ms in the idle sleep, so a disconnect nearly always lands mid-read. The post-callback check at callbacks.rs:234-236 then yields Disconnected, which error.rs:44 maps to DeviceDisconnected. write_request (:204-207) has the same gap.
No impact on the Android app today. HwSendViewModel / HwReceiveViewModel cancel the awaiting job before calling jade_disconnect, and JadeTransport.readBleChunk already errors once closed. So the variant is never observed, and with that transport this was already the outcome before 74efa46. The lifecycle tests miss it because every UserCancelled assertion is on connect, which the manager maps itself. version_info_does_not_wait_for_an_operation_in_flight asserts only is_err().
Narrowest fix, pick one:
- Doc only: "the pending request fails promptly (
UserCancelledorDeviceDisconnected)". - In jade-client-rs, on both error arms:
return Err(if self.aborted.load(Ordering::SeqCst) { JadeError::UserCancelled } else { error }), plus a pin bump.
A remap inside bitkit-core would need extra state, because disconnect_session takes the cancel handle before the op returns. I wouldn't go that way.
The committed bitkitcore.swift predated the doc comments added in 74efa46 and a8e0b99. UniFFI hashes docstrings into the API checksum, so jade_get_version_info and jade_notify_disconnected carried stale values and an app built against this interface would have failed init with apiChecksumMismatch. Regenerated with the same bindgen call build_ios.sh makes; bitkitcoreFFI.h and module.modulemap were already current.
There was a problem hiding this comment.
Verdict: ✅ Approve
Reaudit: diff 5 files.
Findings:
N/A
Security audit: no findings
Coverage:
Unit tests: 100% - The new race test covers the sole production branch added in this delta by proving an in-flight read returns DeviceDisconnected after close
Reviewed by gpt-5.6-sol-high via gh-pr-review-loop skill
Commands: @ovi-reviewer test · retest · audit (author or owner)
jvsena42
left a comment
There was a problem hiding this comment.
Re-reviewed 6644a83. It only touches bindings/ios/bitkitcore.swift. No new findings.
- Complete. It covers every doc comment changed since
9d1486f(the last regeneration) on an exported item:jade_notify_disconnected,jade_get_version_infoand theJadeTransportCallbackdocs. The other doc changes incallbacks.rsandpsbt.rsare on private items, which have no UniFFI checksum. The two new checksums match what CI generated:iOS Bindgen→Check committed iOS interface filespassed on this head. - No generated code changed beyond those docstrings and the two checksum constants.
My note on src/lib.rs:2670 (a disconnect mid-confirmation returns DeviceDisconnected, not UserCancelled) is still open. It's LOW and doesn't need to hold the PR. Android Bindgen was still pending when I checked.
jade_disconnect promised UserCancelled for a request waiting on a confirmation. Which error it actually returns depends on where the crate's read loop is when the link closes: idle between polls it reads the abort flag and reports UserCancelled, but parked inside read_chunk the transport error returns first and the flag is never re-read. Real Bluetooth honours timeout_ms and blocks for up to 250ms per read, so it is usually parked, which the mock hid by answering instantly. Documented both outcomes on jade_disconnect and jade_cancel, and pinned each branch with a test: cancel_reports_user_cancelled_when_the_read_ loop_is_idle and cancel_reports_the_transport_error_when_it_lands_inside _a_read, the latter using a new slow_read flag on the mock. Left the read loop alone, since it belongs to jade-client-rs. Preferring the abort flag over the transport error there is a three line follow-up, after which the second test fails and the docs can be narrowed again. Regenerated bitkitcore.swift: UniFFI hashes docstrings, so jade_cancel and jade_disconnect carried stale API checksums.
The crate now prefers the abort flag over the transport error on both the read and the write path, so a cancelled operation reports UserCancelled whichever it notices first. See synonymdev/jade-client-rs#1. Narrows the jade_disconnect and jade_cancel docs back to promising UserCancelled, and flips cancel_reports_the_transport_error_when_it_lands _inside_a_read to assert it, renamed to cancel_reports_user_cancelled_ when_it_lands_inside_a_read. Both timings of the race are still covered. Regenerated bitkitcore.swift for the doc changes. jade_disconnect returns to checksum 22575, its value before the wording was widened.
Ran build.sh -r --patch all, so Cargo.toml, Package.swift and gradle.properties move together and the Package.swift checksum matches the xcframework this build produced. No binding output changes: the generated Swift carries no version string, and since #154 the Kotlin, the xcframework and the Python package are generated by CI rather than tracked here. Attaching dist/ios/BitkitCore.xcframework.zip to the v0.5.17 release is still a manual step, and it has to be this exact zip or the checksum in Package.swift will not match: b1cd6d47d2fb3e9772805f6bf14041dd5125a21c130543ee26a4d8ac3398d66f
Adds Blockstream Jade as a third hardware wallet, alongside Trezor and Foundation Passport.
The Jade protocol lives in jade-client-rs. 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
jadepysources.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:
Transports. Bluetooth on every platform, driven by the app through a
JadeTransportCallbackin the same shape asTrezorTransportCallback. 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_bytesandserialportout of this repo's direct dependencies. Compared with thetrezor-connect-rssplit:#[uniffi::remote]rather than mirrored structs, so there are no parallel definitions and no hand-writtenFromconversions.JadeTransportErrorCodeinstead of a sentinel string.Worth reviewing deliberately:
d52ccd9. It moves to a published version once the crate is on crates.io.HardwareWalletVendor::Blockstreamis a new enum case. Exhaustive Kotlinwhenand Swiftswitchover the vendor enum will need a new branch, so this is source breaking for consuming apps. Appended afterFoundation, since UniFFI assigns discriminants by declaration order.u8oru16in the FFI surface, keeping this module off the narrow unsigned return path that 0.5.14 fixed for Android ARM32.gradle-publish.ymlnow installs onlyplatform-toolsin the Android SDK step, becausesetup-androidfailed trying to install the removedtoolspackage. A manual run also uploads the native debug symbols when a matching release exists.finalize_psbtnow rejects any signature that does not commit to the whole transaction (ECDSA other thanAll, Schnorr other thanDefaultorAll). This also applies to Passport, which uses the same path.Preview
Not applicable; this is FFI surface with no UI.
src/modules/jade/README.mddocuments the architecture, and the crate's README documents the wire protocol and the Bluetooth contract.QA Notes
Run against a physical Jade v1, firmware 1.0.41, from macOS, over both transports.
wpkhandtrwpkhget_extended_dataSignatures were verified against the keys the account xpub derives, and every PSBT spent a fabricated prevout, so nothing was broadcastable. A 20 input PSBT exercised chunked Bluetooth writes and a fragmented reply reassembled through
get_extended_data.m/0'parent fingerprint route is confirmed: the device signed a PSBT whose BIP32 origins carried that fingerprint.MIN_JADE_FIRMWARE(0.1.48) is still unconfirmed. Only 1.0.41 has been exercised; the device answersUNKNOWN_METHODfor anything it cannot do.Automated:
Blocktank is skipped because its tests reach
api.stag.blocktank.to; that is pre-existing onmaster. Protocol level coverage lives in the crate (72 tests against a scripted mock device and a fake pinserver).Platform gating, since
serialportmust never reach a mobile build:Bluetooth end to end needs an app-side
JadeTransportCallback. Reference material for the Swift and Kotlin sides:examples/callback_transport.rsruns the callback shape against a scripted link with no hardware.docs/bluetooth.mdcovers service UUIDs, chunk sizing per platform, and the rules that fail silently when missed.