Skip to content

feat: Blockstream Jade hardware wallet support - #153

Merged
coreyphillips merged 20 commits into
masterfrom
feat/jade-hardware-wallet
Sep 16, 2026
Merged

coreyphillips merged 20 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 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 jadepy sources.

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. Compared with the trezor-connect-rs split:

  • Types use #[uniffi::remote] rather than mirrored structs, so there are no parallel definitions and no hand-written From conversions.
  • Transport failures cross the boundary as a typed JadeTransportErrorCode instead of a sentinel string.

Worth reviewing deliberately:

  • The dependency is a git rev, not a crates.io version. Pinned at d52ccd9. It moves to a published version once the crate is on crates.io.
  • HardwareWalletVendor::Blockstream is a new enum case. Exhaustive Kotlin when and Swift switch over the vendor enum will need a new branch, so this is source breaking for consuming apps. Appended after Foundation, since UniFFI assigns discriminants by declaration order.
  • No u8 or u16 in the FFI surface, keeping this module off the narrow unsigned return path that 0.5.14 fixed for Android ARM32.
  • Bindings are regenerated and the version is bumped to 0.5.16.
  • gradle-publish.yml now installs only platform-tools in the Android SDK step, because setup-android failed trying to install the removed tools package. A manual run also uploads the native debug symbols when a matching release exists.
  • finalize_psbt now rejects any signature that does not commit to the whole transaction (ECDSA other than All, Schnorr other than Default or All). This also applies to Passport, which uses the same path.

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.

QA Notes

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

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 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.

  • The 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 answers UNKNOWN_METHOD for anything it cannot do.
  • Untested: Jade Plus and Linux serial.

Automated:

cargo clippy --all-targets -- -D warnings
cargo test modules::jade
cargo test modules::hardware_wallet
cargo test -- --skip modules::blocktank

Blocktank is skipped because its tests reach api.stag.blocktank.to; that is pre-existing on master. Protocol level coverage lives in the crate (72 tests against a scripted mock device and a fake pinserver).

Platform gating, since serialport must never reach a mobile build:

cargo check --target aarch64-apple-ios
cargo check --target aarch64-linux-android

Bluetooth end to end needs an app-side JadeTransportCallback. Reference material for the Swift and Kotlin sides:

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.
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
@coreyphillips
coreyphillips marked this pull request as ready for review September 15, 2026 01:22

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread .github/workflows/gradle-publish.yml Outdated
Comment thread src/modules/jade/README.md Outdated
Comment thread src/modules/jade/implementation.rs

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-reply must be pin. No danger_accept_invalid_certs and 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. No unwrap/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. connect closes any prior session first, checks the watch before and after the native open, and cancels the handshake via select. The cancel handle lives outside the session lock, so disconnect returns promptly during an in-flight operation. Lock order is consistent between connect and disconnect_session. Separate manager and callback statics per vendor, with no shared fingerprint, xpub or session cache.
  • Your flagged items all hold. Blockstream is last in the Rust enum and last in both generated bindings, with Trezor=1 and Foundation=2 unchanged. No u8/u16 in any of the 20 jade_* exports. 0.5.16 is consistent across Cargo.toml, Package.swift, gradle.properties and setup.py, and the Package.swift checksum matches the checked-in xcframework zip. Cargo.lock pins d52ccd9099f28aa75fc30f4b94315c2afb64321c. cargo tree for aarch64-linux-android and aarch64-apple-ios shows no serialport.

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.

Comment thread src/modules/jade/implementation.rs
Comment thread src/modules/jade/implementation.rs Outdated
Comment thread src/modules/jade/implementation.rs
Comment thread src/modules/jade/implementation.rs Outdated
Comment thread src/modules/jade/implementation.rs Outdated
- 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

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread .github/workflows/gradle-publish.yml Outdated
TAG: ${{ github.event.release.tag_name || inputs.version }}
shell: bash
run: |
# A manual publish may target a version with no GitHub release; skip the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ovitrif

ovitrif commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Is this PR also integrating the Jade simulator/ emulator? Or it is more HTTP-based, thus a better fit for bitkit-docker?

@coreyphillips

Copy link
Copy Markdown
Collaborator Author

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 (tcp:localhost:30121). So it's effectively a third transport alongside the two here (Bluetooth via the foreign callback, USB CDC serial on desktop), not a web service.

The only HTTP in the Jade path is the pinserver, and that's Blockstream's hosted blind PIN oracle reached over HTTPS from jade-client-rs (reqwest-pinserver feature). It's remote, so it doesn't need a container.

If we do want emulator coverage later, the split would be:

  • a TcpTransport in jade-client-rs, where the protocol and its tests already live. It can't go in bitkit-core: bindings are generated from the host library, so a desktop-only #[uniffi::export] would show up in the generated Swift and Kotlin while being missing from the device library.
  • the blockstream/jade-emulator container in bitkit-docker as a dev service, which is the part your instinct is right about.

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 cargo test modules::jade, and the protocol level is covered in jade-client-rs against a scripted mock device and a fake pinserver.

On Passport: our support for it is UR/QR only (src/modules/ur/passport.rs), airgapped with no transport at all, so its simulator sits at a different layer than anything this PR touches.

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.
@jvsena42

Copy link
Copy Markdown
Member

@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.
@coreyphillips

Copy link
Copy Markdown
Collaborator Author

Conflicts resolved in 76eebaf, master merged in.

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 .gitignore. Took master's deletion in all eight cases, so the generated Kotlin, the xcframework and the Python package are no longer checked in here either. The new bindgen workflows regenerate them.

Nothing in src/ or the build scripts conflicted, and gradle-publish.yml kept this branch's version since master hasn't touched that file since the merge base. Versions are still in sync at 0.5.16 across Cargo.toml and gradle.properties, with Package.swift now on master's tag interpolation.

Checked against what Bindgen Validation runs: bash -n on the four build scripts, cargo metadata --locked, and the workflow YAML all pass. cargo test modules::jade is 19/19 and cargo test modules::activity is 196/196.

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_types runs after interpreter_check over every input and reads final_script_sig / final_script_witness, so inputs a signer returns already finalized are covered too. Signatures are 64-byte (Default) or 65-byte with 0x01, and both are accepted. Key-spend, P2PKH/P2WPKH/P2SH-P2WPKH and script paths including multi_a all produce a signature constraint, so none is skipped. The walk re-evaluates the same satisfaction interpreter_check already verified, so it cannot newly reject a PSBT that Passport or Jade legitimately signs.
  • send_replace in notify_disconnected: holds. It is gone, and jade_notify_disconnected documents the await-before-reconnect rule.
  • Disconnect during the handshake: holds. closed belongs to each CallbackTransport, and build_transport creates a fresh one on every connect, so reconnecting to the same path cannot inherit a stale flag.
  • jade_scan on mobile without a callback: holds. It returns NotInitialized without touching the previous device list.
  • jade_get_version_info: holds. It reads only the RwLock.
  • Bot threads (workflow guard, README, adapter tests, in-flight read_chunk docs): all hold.

Also clean

  • The remerge diff of 76eebaf is exactly the eight generated-artifact deletions. Nothing under src/, Cargo.* or Package.swift came from conflict resolution.
  • The delta adds no logging of PSBT bytes, xpubs, paths or version details.
  • No FFI signature changed between the v0.5.16 tag and head. The only semantic changes (jade_scan, jade_get_version_info, finalize_psbt) all favour the consumer.
  • finalize_psbt has no caller on bitkit-android or bitkit-ios master yet. 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.

Comment thread src/lib.rs
/// 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of the time, a disconnect during a confirmation wait returns DeviceDisconnected, not the UserCancelled this doc promises.

disconnect_sessionCancelHandle::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 (UserCancelled or DeviceDisconnected)".
  • 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.

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_info and the JadeTransportCallback docs. The other doc changes in callbacks.rs and psbt.rs are on private items, which have no UniFFI checksum. The two new checksums match what CI generated: iOS BindgenCheck committed iOS interface files passed 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
@coreyphillips
coreyphillips merged commit 3613331 into master Sep 16, 2026
5 checks passed
@coreyphillips
coreyphillips deleted the feat/jade-hardware-wallet branch September 16, 2026 15:37
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.

3 participants