diff --git a/AGENTS.md b/AGENTS.md index 8af2db3..c182e83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ Android bindings are built and published by `.github/workflows/gradle-publish.ym ```bash cargo test # All tests -cargo test modules:: # Single module (scanner, lnurl, onchain, activity, blocktank, trezor, pubky) +cargo test modules:: # Single module (scanner, lnurl, onchain, activity, blocktank, boltz, trezor, jade, hardware_wallet, ur, pubky) ``` ## Lint & Format @@ -35,7 +35,7 @@ Android bindings use ktlint via Gradle plugin (`org.jlleitschuh.gradle.ktlint`), ## Architecture - `src/lib.rs` — UniFFI exports and module re-exports -- `src/modules/` — Core modules: scanner, lnurl, onchain, activity, blocktank, trezor, pubky +- `src/modules/`: core modules: scanner, lnurl, onchain, activity, blocktank, boltz, trezor, jade, hardware_wallet, ur, pubky - `bindings/` — Platform-specific binding outputs (ios/, android/, python/) - `build.sh`, `build_ios.sh`, `build_android.sh`, `build_python.sh` — Build scripts @@ -43,7 +43,8 @@ Android bindings use ktlint via Gradle plugin (`org.jlleitschuh.gradle.ktlint`), - **Version sync**: Version must match across `Cargo.toml`, `Package.swift`, and `bindings/android/gradle.properties`. Use `build.sh -r` to bump all three. - **UniFFI**: Public types exposed to bindings are declared in `src/lib.rs`. Follow existing UniFFI patterns when adding new types. -- **Platform-specific deps**: Trezor uses Bluetooth-only on iOS, USB+Bluetooth on other platforms (see `Cargo.toml` target-specific dependencies). +- **Platform-specific deps**: Trezor uses Bluetooth-only on iOS, USB+Bluetooth on other platforms (see `Cargo.toml` target-specific dependencies). Jade's serial transport is desktop-only; `serialport` must keep `default-features = false` or CI loses `libudev`. +- **No cfg-gated UniFFI exports**: bindings are generated from the host library, so a host-only `#[uniffi::export]` would appear in the Swift and Kotlin output while being absent from the device library. - **Android build**: `build_android.sh` temporarily modifies `Cargo.toml` crate-type and removes `example/main.rs` during build — don't run concurrent builds. - **Android bindings**: Keep `bindings/android/lib/src/main/jniLibs/` untracked. GitHub Actions generates the JNI libraries before publishing the Android package. diff --git a/CHANGELOG.md b/CHANGELOG.md index 183e6fe..1fe1dea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Add Blockstream Jade hardware wallet support: device discovery, connect, PIN unlock via the blind pinserver, extended public key and account export, on-device address verification, message signing, and PSBT signing, over Bluetooth on every platform and USB CDC serial on desktop and Python. Signed PSBTs feed the existing `finalize_psbt` path. The protocol lives in the `jade-client-rs` crate; this repo carries the UniFFI adapter. +- Add `HardwareWalletVendor.Blockstream` and catalog entries for Jade and Jade Plus. Note that adding an enum case makes exhaustive Kotlin `when` and Swift `switch` statements over `HardwareWalletVendor` non-exhaustive, which is source breaking for consumers. + ## 0.5.14 - 2026-09-02 - Prevent Android ARM32 startup crashes by generating `Int` carriers for direct unsigned 8-bit and 16-bit UniFFI returns while preserving Kotlin unsigned APIs. diff --git a/Cargo.lock b/Cargo.lock index 3896201..46a2e10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -602,6 +602,7 @@ dependencies = [ "btleplug", "chrono", "hex", + "jade-client-rs", "jni", "lazy-regex", "lightning-invoice 0.32.0", @@ -967,6 +968,33 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1204,6 +1232,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -2016,6 +2050,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hash32" version = "0.2.1" @@ -2452,6 +2497,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2489,6 +2544,29 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jade-client-rs" +version = "0.1.0" +source = "git+https://github.com/coreyphillips/jade-client-rs?rev=d55fafb#d55fafbca117fa1da7c3863ae53ea1d69d142e18" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bitcoin 0.32.8", + "ciborium", + "log", + "minicbor", + "rand 0.8.5", + "reqwest", + "serde", + "serde_bytes", + "serde_json", + "serialport", + "thiserror 2.0.18", + "tokio", + "url", + "zeroize", +] + [[package]] name = "jiff" version = "0.2.29" @@ -2828,6 +2906,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "mainline" version = "5.4.0" @@ -2948,6 +3035,17 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -4595,6 +4693,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serialport" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a2f4ac56b5d3af3c40fbbee17be96d532cba02fa5853926aacdb77d926272ab" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "core-foundation", + "core-foundation-sys", + "io-kit-sys", + "mach2", + "nix", + "scopeguard", + "unescaper", + "windows-sys 0.52.0", +] + [[package]] name = "sha1" version = "0.10.6" @@ -5342,6 +5458,15 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unescaper" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7285e83a80ce76f5e7bce79fa41f68d78ba62d1003cf27bf748ab24413808cf4" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index 4a24f98..3cc7e42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ path = "src/lib.rs" uniffi = { version = "0.29.4", features = [ "cli", "bindgen" ] } serde_json = "1.0.114" serde = { version = "^1.0.209", features = ["derive"] } -tokio = { version = "1.40.0", features = ["rt", "rt-multi-thread", "macros"] } +tokio = { version = "1.40.0", features = ["rt", "rt-multi-thread", "macros", "time", "sync"] } bitcoin = "0.32.4" miniscript = "12.3.7" chrono = "0.4" @@ -62,6 +62,16 @@ trezor-connect-rs = { version = "0.4.0", default-features = false, features = [" jni = "0.19" android_logger = "0.14" +# Jade hardware wallet protocol. Bluetooth is driven by the native application +# through JadeTransportCallback, so the crate's own serial transport is only +# wanted where a Rust side serial port makes sense. +[target.'cfg(any(target_os = "ios", target_os = "android"))'.dependencies] +jade-client-rs = { git = "https://github.com/coreyphillips/jade-client-rs", rev = "d55fafb", default-features = false, features = ["reqwest-pinserver"] } + +# Desktop and Python additionally get the crate's serial transport. +[target.'cfg(not(any(target_os = "ios", target_os = "android")))'.dependencies] +jade-client-rs = { git = "https://github.com/coreyphillips/jade-client-rs", rev = "d55fafb", features = ["reqwest-pinserver", "serial"] } + [dev-dependencies] tokio = { version = "1.40.0", features = ["full"] } serde_json = "1.0.114" diff --git a/src/lib.rs b/src/lib.rs index 0b4cfab..e3ef2b9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,6 +46,13 @@ pub use crate::modules::hardware_wallet::{ get_supported_hardware_wallets, HardwareWalletTransport, HardwareWalletVendor, SupportedHardwareWallet, }; +use crate::modules::jade::JadeManager; +pub use crate::modules::jade::{ + jade_set_transport_callback, JadeAccount, JadeAccountExport, JadeAddressVariant, + JadeDeviceInfo, JadeError, JadeNativeDevice, JadeNetwork, JadePingStatus, JadeSignedMessage, + JadeState, JadeTransportCallback, JadeTransportErrorCode, JadeTransportKind, + JadeTransportReadResult, JadeTransportResult, JadeVersionInfo, JadeXpubResponse, +}; use crate::modules::pubky::{PubkyAuthDetails, PubkyAuthKind, PubkyError, PubkyProfile}; use crate::modules::trezor::account_type_to_script_type; pub use crate::modules::trezor::{ @@ -104,6 +111,7 @@ static DB: OnceCell> = OnceCell::new(); static ASYNC_DB: OnceCell> = OnceCell::new(); static RUNTIME: OnceCell = OnceCell::new(); static TREZOR_MANAGER: OnceCell = OnceCell::new(); +static JADE_MANAGER: OnceCell = OnceCell::new(); fn ensure_runtime() -> &'static Runtime { RUNTIME.get_or_init(|| Runtime::new().expect("Failed to create Tokio runtime")) @@ -2598,8 +2606,312 @@ pub async fn trezor_clear_credentials(device_id: String) -> Result<(), TrezorErr }) } +// ============================================================================ +// Jade Hardware Wallet Functions +// ============================================================================ + +fn get_jade_manager() -> &'static JadeManager { + JADE_MANAGER.get_or_init(JadeManager::new) +} + +/// Discover Jade devices. +/// +/// Bluetooth discovery is performed by the registered transport callback; on +/// desktop and Python builds, attached USB serial units are enumerated too. +/// Returns `DeviceBusy` while a connection is open, because starting a +/// Bluetooth scan during an active link drops it on Android. +#[uniffi::export] +pub async fn jade_scan(timeout_ms: u32) -> Result, JadeError> { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().scan(timeout_ms).await }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// The devices found by the last scan, without starting a new one. +#[uniffi::export] +pub async fn jade_list_devices() -> Vec { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().list_devices().await }) + .await + .unwrap_or_default() +} + +/// Open a device and read its firmware and state summary. +/// +/// Any previously open connection is closed first. The returned `jade_state` +/// tells the application what to do next: `Locked` means call `jade_unlock`, +/// `Ready` means the device is already usable, and `Uninit` means the user must +/// create or restore a wallet on the device itself. +#[uniffi::export] +pub async fn jade_connect( + transport: JadeTransportKind, + path: String, +) -> Result { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().connect(transport, &path).await }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// 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. +#[uniffi::export] +pub async fn jade_disconnect() -> Result<(), JadeError> { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().disconnect().await }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// Abort the operation in flight. +/// +/// Jade has no cancel message, so this closes the link. The application should +/// reconnect afterwards. This is what backs a cancel button on a signing screen. +#[uniffi::export] +pub async fn jade_cancel() -> Result<(), JadeError> { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().cancel().await }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// Tell the library that the native layer saw the device disconnect. +/// +/// Without this, an idle Bluetooth drop is invisible until the next request. +#[uniffi::export] +pub async fn jade_notify_disconnected(path: String) { + let rt = ensure_runtime(); + let _ = rt + .spawn(async move { get_jade_manager().notify_disconnected(&path).await }) + .await; +} + +#[uniffi::export] +pub fn jade_is_connected() -> bool { + get_jade_manager().is_connected() +} + +#[uniffi::export] +pub async fn jade_get_connected_device() -> Option { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().connected_device().await }) + .await + .unwrap_or(None) +} + +/// The version summary read at connect, without touching the device. +#[uniffi::export] +pub async fn jade_get_version_info() -> Option { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().version_info().await }) + .await + .unwrap_or(None) +} + +/// Re-read the version summary from the device. +#[uniffi::export] +pub async fn jade_refresh_version_info() -> Result { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().refresh_version_info().await }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// Check whether the device is idle, busy, or waiting on the user. +#[uniffi::export] +pub async fn jade_ping() -> Result { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().ping().await }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// Unlock the device for a network. +/// +/// Runs the blind pinserver exchange when the device asks for it, which needs +/// network access. The PIN is entered on the device and never reaches the host. +#[uniffi::export] +pub async fn jade_unlock(network: JadeNetwork) -> Result<(), JadeError> { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().unlock(network).await }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// Lock the device and zero its in-memory key material. +#[uniffi::export] +pub async fn jade_logout() -> Result<(), JadeError> { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().logout().await }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// Fetch an extended public key, echoed back with the path and fingerprint. +#[uniffi::export] +pub async fn jade_get_xpub( + network: JadeNetwork, + derivation_path: String, +) -> Result { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().get_xpub(network, derivation_path).await }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// The device's master fingerprint, eight lowercase hex characters. +/// +/// This must be supplied as `WalletParams.fingerprint` when composing, or the +/// resulting PSBT carries no BIP32 key origins and the device signs nothing. +#[uniffi::export] +pub async fn jade_get_master_fingerprint(network: JadeNetwork) -> Result { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().master_fingerprint(network).await }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// Fetch the account keys an import needs in one call. +/// +/// Shaped like `passport_parse_account_export` so applications have a single +/// import path across signers. Each key is fetched under one held connection, +/// which matters over Bluetooth where every round trip is slow. +#[uniffi::export] +pub async fn jade_get_account_export( + network: JadeNetwork, + account_index: u32, + account_types: Vec, +) -> Result { + let rt = ensure_runtime(); + rt.spawn(async move { + get_jade_manager() + .account_export(network, account_index, account_types) + .await + }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// Display an address on the device and check it against the expected one. +/// +/// This always prompts on the device screen, so it is a verification step +/// rather than a way to fetch an address. Returns `AddressMismatch` when the +/// device disagrees with `expected_address`. +#[uniffi::export] +pub async fn jade_verify_address( + network: JadeNetwork, + variant: JadeAddressVariant, + derivation_path: String, + expected_address: String, +) -> Result<(), JadeError> { + let rt = ensure_runtime(); + rt.spawn(async move { + get_jade_manager() + .verify_address(network, variant, derivation_path, expected_address) + .await + }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// Sign a message, returning the signature with the address that verifies it. +#[uniffi::export] +pub async fn jade_sign_message( + network: JadeNetwork, + derivation_path: String, + message: String, +) -> Result { + let rt = ensure_runtime(); + rt.spawn(async move { + get_jade_manager() + .sign_message(network, derivation_path, message) + .await + }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// Sign a PSBT, returning the signed PSBT base64 encoded. +/// +/// The reply is checked against what was sent before it is returned. Feed the +/// result to `finalize_psbt` with the original PSBT, then broadcast with +/// `onchain_broadcast_raw_tx`. +#[uniffi::export] +pub async fn jade_sign_psbt(network: JadeNetwork, psbt: String) -> Result { + let rt = ensure_runtime(); + rt.spawn(async move { get_jade_manager().sign_psbt(network, psbt).await }) + .await + .unwrap_or_else(|e| { + Err(JadeError::IoError { + error_details: format!("Runtime error: {}", e), + }) + }) +} + +/// Map a generic account type onto Jade's descriptor variant. +#[uniffi::export] +pub fn jade_account_type_to_variant(account_type: AccountType) -> JadeAddressVariant { + crate::modules::jade::account_type_to_variant(account_type) +} + // ============================================================================ // Account info FFI exports + // ============================================================================ /// Query account information for an extended public key via Electrum. diff --git a/src/modules/hardware_wallet/catalog.rs b/src/modules/hardware_wallet/catalog.rs index 6180ff1..a3ef820 100644 --- a/src/modules/hardware_wallet/catalog.rs +++ b/src/modules/hardware_wallet/catalog.rs @@ -13,6 +13,14 @@ pub fn get_supported_hardware_wallets() -> Vec { transports, }; + let jade = |model: &str, display_name: &str| SupportedHardwareWallet { + vendor: HardwareWalletVendor::Blockstream, + vendor_name: "Blockstream".to_string(), + model: model.to_string(), + display_name: display_name.to_string(), + transports: vec![Usb, Bluetooth], + }; + vec![ trezor("Model One", vec![Usb]), trezor("Model T", vec![Usb]), @@ -26,5 +34,9 @@ pub fn get_supported_hardware_wallets() -> Vec { display_name: "Foundation Passport".to_string(), transports: vec![Qr], }, + // Jade's USB link is CDC serial rather than HID, reported here as Usb + // because that is what a user plugs in. + jade("Jade", "Blockstream Jade"), + jade("Jade Plus", "Blockstream Jade Plus"), ] } diff --git a/src/modules/hardware_wallet/tests.rs b/src/modules/hardware_wallet/tests.rs index d13ca08..ace3d9c 100644 --- a/src/modules/hardware_wallet/tests.rs +++ b/src/modules/hardware_wallet/tests.rs @@ -4,7 +4,7 @@ use super::{get_supported_hardware_wallets, HardwareWalletTransport, HardwareWal fn catalog_lists_supported_models_and_transports() { let wallets = get_supported_hardware_wallets(); - assert_eq!(wallets.len(), 6); + assert_eq!(wallets.len(), 8); assert!(wallets .iter() .filter(|wallet| wallet.vendor == HardwareWalletVendor::Trezor) @@ -24,4 +24,17 @@ fn catalog_lists_supported_models_and_transports() { .unwrap(); assert_eq!(passport.vendor, HardwareWalletVendor::Foundation); assert_eq!(passport.transports, [HardwareWalletTransport::Qr]); + + let jades: Vec<_> = wallets + .iter() + .filter(|wallet| wallet.vendor == HardwareWalletVendor::Blockstream) + .collect(); + assert_eq!(jades.len(), 2); + assert!(jades.iter().all(|wallet| { + wallet.transports.contains(&HardwareWalletTransport::Usb) + && wallet + .transports + .contains(&HardwareWalletTransport::Bluetooth) + })); + assert!(jades.iter().any(|wallet| wallet.model == "Jade Plus")); } diff --git a/src/modules/hardware_wallet/types.rs b/src/modules/hardware_wallet/types.rs index f8ce601..325e69e 100644 --- a/src/modules/hardware_wallet/types.rs +++ b/src/modules/hardware_wallet/types.rs @@ -3,6 +3,7 @@ pub enum HardwareWalletVendor { Trezor, Foundation, + Blockstream, } /// How an application exchanges data with a hardware wallet. diff --git a/src/modules/jade/README.md b/src/modules/jade/README.md new file mode 100644 index 0000000..4ab201c --- /dev/null +++ b/src/modules/jade/README.md @@ -0,0 +1,118 @@ +# Jade Module - Technical Overview + +Blockstream Jade support for bitkit-core, over Bluetooth (all platforms) and USB +CDC serial (desktop and Python). Bitcoin single signature only. + +The protocol itself lives in +[`jade-client-rs`](https://github.com/coreyphillips/jade-client-rs). This module +is the FFI adapter. For the wire format, the pinserver exchange, PSBT checks and +the transport contract, read that crate's documentation; what follows is only +what is specific to bitkit-core. + +## Architecture + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ bitkit-android / bitkit-ios │ +│ implements JadeTransportCallback: BLE, and USB host on Android │ +└───────────────────────────────┬──────────────────────────────────────┘ + │ UniFFI +┌───────────────────────────────▼──────────────────────────────────────┐ +│ bitkit-core │ +│ lib.rs jade_* exports over a global JadeManager │ +│ implementation.rs session lock, device list, abort handle │ +│ callbacks.rs JadeTransportCallback + bridge to JadeTransport │ +│ types.rs #[uniffi::remote] scaffolding for crate types │ +└───────────────────────────────┬──────────────────────────────────────┘ + │ +┌───────────────────────────────▼──────────────────────────────────────┐ +│ jade-client-rs │ +│ CBOR protocol, pinserver, PSBT checks, serial transport │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +## Why the types are declared with `#[uniffi::remote]` + +The crate's types carry no binding framework. `types.rs` attaches UniFFI +scaffolding to them from here, which generates the same code a +`#[derive(uniffi::…)]` would without a mirrored set of structs. + +This is the main way this module differs from `trezor`, which predates the +technique: that module maintains roughly 900 lines of parallel types and +hand-written `From` conversions in both directions against +`trezor-connect-rs`. The declarations here have to match upstream field for +field, and the compiler enforces it. + +One consequence worth knowing: `#[uniffi::remote(Error)]` needs to match every +variant, so `jade_client_rs::JadeError` deliberately is not `#[non_exhaustive]`. + +## Session state + +`jade_client_rs::Jade` takes `&mut self` per operation, so the one request at a +time rule is a compile time property there. A free-function FFI surface needs a +process global, so `JadeManager` supplies the lock that implies. + +The abort handle is kept outside that lock on purpose. Sharing one lock would +make `jade_disconnect` and every status read queue behind a five minute +confirmation, and UniFFI async exports are detached onto the runtime, so a +cancelled Swift or Kotlin task does not cancel the Rust future by itself. +`jade_cancel` and `jade_disconnect` therefore close the transport through a +`CancelHandle` without taking the session lock. + +## Transport bridge + +`JadeTransportCallback` is the `#[uniffi::export(with_foreign)]` trait the +application implements; `CallbackTransport` adapts it onto the crate's +`JadeTransport`. Every callback invocation runs on the tokio blocking pool, so a +slow implementation costs a blocking thread rather than a runtime worker. + +The full Bluetooth contract, including the two second inter-chunk deadline and +the write-with-response requirement, is documented on the trait and in the +crate's README. Read it before writing a native implementation; each of those +rules fails only against real hardware. + +Errors cross the boundary as a typed `JadeTransportErrorCode` rather than an +error string. 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. + +## Signing + +Jade returns a signed PSBT, so it follows the Passport path: + +``` +onchain_compose_transaction -> psbt (base64) +jade_sign_psbt -> signed psbt (base64) +finalize_psbt(original, signed) -> CompletedTransaction +onchain_broadcast_raw_tx +``` + +`WalletParams.fingerprint` must be set to the value from +`jade_get_master_fingerprint`, or the composed PSBT carries no BIP32 key origins +and the device signs nothing. The crate rejects that case before the round trip +with `FingerprintMismatch`. + +## Constraints + +- No `#[uniffi::export]` item here may be `cfg` gated. All three build scripts + generate bindings from the host library, so a host only export would appear in + the generated Swift and Kotlin while being absent from the device library. +- No `u8` or `u16` in the FFI surface. `ping` returns `JadePingStatus` and + `battery_status` is `u32`, keeping this module clear of the narrow unsigned + return path that needed a binding generator fix for Android ARM32. +- Registering a transport callback twice replaces the first, so an Android + activity restart can re-register. The replacement is logged. + +## Dependency + +Pinned by git revision until the crate is published to crates.io, so bitkit-core +never depends on an unreleased version. Bumping it means updating both target +tables in `Cargo.toml`. + +## Testing + +```bash +cargo test modules::jade # adapter only +``` + +Protocol level tests live in the crate and run with `cargo test` there, against +a scripted mock device and a fake pinserver. diff --git a/src/modules/jade/callbacks.rs b/src/modules/jade/callbacks.rs new file mode 100644 index 0000000..d05f08e --- /dev/null +++ b/src/modules/jade/callbacks.rs @@ -0,0 +1,230 @@ +//! The transport contract the native application implements, and the bridge +//! from it to the protocol crate's transport trait. +//! +//! Rust owns the Jade protocol; the application owns the bytes. On iOS that +//! means CoreBluetooth against the Nordic UART Service, and on Android the +//! Bluetooth API plus, optionally, the USB Host API for CDC serial. +//! +//! Methods are synchronous because that is the shape the trezor module already +//! established here. Every one of them is invoked on the blocking pool, so a +//! slow implementation costs a blocking thread rather than a runtime worker. + +use std::sync::{Arc, RwLock}; +use std::time::Duration; + +use async_trait::async_trait; +use jade_client_rs::{JadeError, JadeTransport, JadeTransportErrorCode, MAX_CHUNK_BYTES}; + +use super::types::JadeTransportKind; + +/// A device the native layer discovered. +#[derive(Debug, Clone, uniffi::Record)] +pub struct JadeNativeDevice { + /// Transport specific address: a BLE identifier or a serial device path. + pub path: String, + pub transport: JadeTransportKind, + /// Advertised or descriptor name, for example "Jade C0FFEE". + pub name: Option, + pub serial_number: Option, +} + +/// Outcome of an operation that returns no data. +#[derive(Debug, Clone, uniffi::Record)] +pub struct JadeTransportResult { + pub success: bool, + /// Empty on success. + pub error: String, + pub error_code: Option, +} + +/// Outcome of a read. +#[derive(Debug, Clone, uniffi::Record)] +pub struct JadeTransportReadResult { + pub success: bool, + /// Bytes read. Success with an empty vector means nothing has arrived yet, + /// which is the normal case while the user is deciding on the device. + pub data: Vec, + /// Empty on success. + pub error: String, + pub error_code: Option, +} + +/// Native transport for Jade. +/// +/// # Bluetooth contract +/// +/// Jade advertises the Nordic UART Service: +/// +/// - service `6e400001-b5a3-f393-e0a9-e50e24dcca9e` +/// - write `6e400002-b5a3-f393-e0a9-e50e24dcca9e` (host to Jade) +/// - notify `6e400003-b5a3-f393-e0a9-e50e24dcca9e` (Jade to host) +/// +/// Devices advertise as "Jade" or "Jade ". +/// +/// Three requirements that are easy to miss and break signing on real hardware: +/// +/// 1. **Write with response.** Write-without-response silently drops chunks on +/// the ESP32 GATT stack. +/// 2. **Do not pause between chunks.** Firmware discards a partially received +/// message after two seconds of silence, three on Jade v1, and answers with +/// an unattributed error. A 30 KB PSBT is roughly 60 writes, so any UI thread +/// stall in the middle of a send breaks the operation. +/// 3. **`read_chunk` must return promptly.** Honour `timeout_ms`, which this +/// crate keeps short. The long per-operation deadline is enforced in Rust so +/// the user can cancel. +#[uniffi::export(with_foreign)] +pub trait JadeTransportCallback: Send + Sync { + /// Discover devices, blocking up to `timeout_ms`. + fn scan_devices(&self, timeout_ms: u32) -> Vec; + + /// Open a connection and enable notifications. + fn open_device(&self, path: String) -> JadeTransportResult; + + /// Close the connection and release the device. + fn close_device(&self, path: String) -> JadeTransportResult; + + /// Write one chunk, no larger than `get_chunk_size`. + fn write_chunk(&self, path: String, data: Vec) -> JadeTransportResult; + + /// Read whatever has arrived, waiting at most `timeout_ms`. + /// + /// Returning success with an empty vector is normal and means "nothing yet". + fn read_chunk(&self, path: String, timeout_ms: u32) -> JadeTransportReadResult; + + /// Maximum bytes per write. + /// + /// For Bluetooth this is `min(negotiated_mtu - 3, 509)`. The value is + /// clamped into a usable range, so an unnegotiated `0` is not fatal. + fn get_chunk_size(&self, path: String) -> u32; +} + +/// The registered callback. +/// +/// A read-write cell rather than a write-once cell on purpose. An Android +/// activity restart rebuilds the Bluetooth manager and registers a fresh +/// implementation; silently keeping the first one would leave this crate calling +/// into a dead context with no recovery short of killing the process. +static TRANSPORT_CALLBACK: RwLock>> = RwLock::new(None); + +/// Register the native transport. +/// +/// Returns `true` when this replaced a previously registered callback, which +/// lets the application tell a fresh registration from a re-registration. +#[uniffi::export] +pub fn jade_set_transport_callback(callback: Arc) -> bool { + #[cfg(target_os = "android")] + crate::init_android_logger(); + + let mut guard = TRANSPORT_CALLBACK + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let replaced = guard.is_some(); + if replaced { + log::warn!("[jade] transport callback replaced"); + } + *guard = Some(callback); + replaced +} + +/// Fetch the registered transport, if any. +pub(crate) fn transport_callback() -> Option> { + TRANSPORT_CALLBACK + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() +} + +fn to_error(code: Option, message: String) -> JadeError { + match code { + Some(code) => JadeError::from(code), + None => JadeError::TransportError { + error_details: message, + }, + } +} + +/// Bridges the foreign callback onto the protocol crate's transport trait. +/// +/// The error code travels as a typed value the whole way, so nothing has to be +/// encoded into an error string and parsed back out. The trezor adapter in this +/// repo does exactly that, because its upstream crate offers no typed channel. +pub(crate) struct CallbackTransport { + callback: Arc, + path: String, + chunk_size: usize, +} + +impl CallbackTransport { + pub(crate) fn new(callback: Arc, path: String) -> Self { + // Clamp whatever the native layer reports. A zero would make the write + // loop fail to advance, and anything above the Bluetooth cap would be + // rejected by the link layer. + let reported = callback.get_chunk_size(path.clone()); + let chunk_size = reported.clamp(1, MAX_CHUNK_BYTES) as usize; + Self { + callback, + path, + chunk_size, + } + } +} + +#[async_trait] +impl JadeTransport for CallbackTransport { + async fn write_all(&self, data: Vec) -> Result<(), JadeError> { + let callback = Arc::clone(&self.callback); + let path = self.path.clone(); + let chunk_size = self.chunk_size; + + // Foreign callbacks are synchronous and can block. Running them on a + // worker thread would park it for the duration; the blocking pool is + // sized for exactly this. + tokio::task::spawn_blocking(move || { + for chunk in data.chunks(chunk_size) { + let result = callback.write_chunk(path.clone(), chunk.to_vec()); + if !result.success { + return Err(to_error(result.error_code, result.error)); + } + } + Ok(()) + }) + .await + .map_err(|error| JadeError::IoError { + error_details: format!("write task failed: {error}"), + })? + } + + async fn read_some(&self, timeout: Duration) -> Result, JadeError> { + let callback = Arc::clone(&self.callback); + let path = self.path.clone(); + let timeout_ms = timeout.as_millis().min(u128::from(u32::MAX)) as u32; + + tokio::task::spawn_blocking(move || { + let result = callback.read_chunk(path, timeout_ms); + if !result.success { + return Err(to_error(result.error_code, result.error)); + } + Ok(result.data) + }) + .await + .map_err(|error| JadeError::IoError { + error_details: format!("read task failed: {error}"), + })? + } + + async fn close(&self) -> Result<(), JadeError> { + let callback = Arc::clone(&self.callback); + let path = self.path.clone(); + tokio::task::spawn_blocking(move || { + let result = callback.close_device(path); + if !result.success { + return Err(to_error(result.error_code, result.error)); + } + Ok(()) + }) + .await + .map_err(|error| JadeError::IoError { + error_details: format!("close task failed: {error}"), + })? + } +} diff --git a/src/modules/jade/implementation.rs b/src/modules/jade/implementation.rs new file mode 100644 index 0000000..35d3fa6 --- /dev/null +++ b/src/modules/jade/implementation.rs @@ -0,0 +1,366 @@ +//! Session state for the FFI surface. +//! +//! `jade_client_rs::Jade` takes `&mut self` for every operation, which makes the +//! one-request-at-a-time rule a compile time property. The FFI surface here is a +//! set of free functions over a process global, so this adds the lock that shape +//! implies, plus the device list and the abort handle. +//! +//! The abort handle is deliberately kept outside the session lock. Holding one +//! lock for both would make `jade_disconnect` and every status read queue behind +//! a five minute confirmation, and UniFFI async exports are detached onto the +//! runtime, so a cancelled Swift or Kotlin task does not cancel the Rust future +//! by itself. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use bitcoin::psbt::Psbt; +use jade_client_rs::{CancelHandle, Jade, JadeTransport}; +use tokio::sync::{Mutex, RwLock}; + +use super::callbacks::{transport_callback, CallbackTransport}; +use super::types::*; +use crate::onchain::AccountType; + +/// A device seen by the last scan. +#[derive(Debug, Clone)] +struct CachedDevice { + info: JadeDeviceInfo, +} + +pub struct JadeManager { + device_list: Mutex>, + /// Held for exactly one operation. + session: Mutex>, + /// Cloned out by the abort path, which must not wait on `session`. + cancel: RwLock>, + /// Cheap status reads that never touch a lock held across device I/O. + connected: AtomicBool, + connected_device: RwLock>, +} + +impl Default for JadeManager { + fn default() -> Self { + Self::new() + } +} + +impl JadeManager { + pub fn new() -> Self { + Self { + device_list: Mutex::new(Vec::new()), + session: Mutex::new(None), + cancel: RwLock::new(None), + connected: AtomicBool::new(false), + connected_device: RwLock::new(None), + } + } + + // ------------------------------------------------------------------ + // Discovery + // ------------------------------------------------------------------ + + /// Discover devices on every transport this build supports. + pub async fn scan(&self, timeout_ms: u32) -> Result, JadeError> { + // Starting a Bluetooth scan while a GATT link is up reliably drops it on + // Android, so refuse rather than silently breaking the open session. + if self.connected.load(Ordering::SeqCst) { + return Err(JadeError::DeviceBusy); + } + + let mut discovered: Vec = Vec::new(); + + if let Some(callback) = transport_callback() { + let found = tokio::task::spawn_blocking(move || callback.scan_devices(timeout_ms)) + .await + .map_err(|error| JadeError::IoError { + error_details: format!("scan task failed: {error}"), + })?; + discovered.extend(found.into_iter().map(|device| JadeDeviceInfo { + path: device.path, + transport: device.transport, + name: device.name, + serial_number: device.serial_number, + })); + } + + #[cfg(not(any(target_os = "ios", target_os = "android")))] + discovered.extend(jade_client_rs::serial::enumerate_devices()); + + *self.device_list.lock().await = discovered + .iter() + .cloned() + .map(|info| CachedDevice { info }) + .collect(); + Ok(discovered) + } + + /// The devices found by the last scan. + pub async fn list_devices(&self) -> Vec { + self.device_list + .lock() + .await + .iter() + .map(|device| device.info.clone()) + .collect() + } + + // ------------------------------------------------------------------ + // Connection lifecycle + // ------------------------------------------------------------------ + + /// Open a device and read its version summary. + pub async fn connect( + &self, + transport_kind: JadeTransportKind, + path: &str, + ) -> Result { + let device = { + let devices = self.device_list.lock().await; + devices + .iter() + .find(|candidate| { + candidate.info.transport == transport_kind && candidate.info.path == path + }) + .map(|candidate| candidate.info.clone()) + .ok_or(JadeError::DeviceNotFound)? + }; + + // Close anything already open first. Overwriting the session would + // strand the native handle with no path left to close it. + self.disconnect().await?; + + let transport = self.build_transport(transport_kind, path).await?; + let session = Jade::connect(transport).await?; + let version = session.version_info().clone(); + + *self.cancel.write().await = Some(session.cancel_handle()); + *self.connected_device.write().await = Some(device); + *self.session.lock().await = Some(session); + self.connected.store(true, Ordering::SeqCst); + + Ok(version) + } + + async fn build_transport( + &self, + transport_kind: JadeTransportKind, + path: &str, + ) -> Result, JadeError> { + // A serial device found by the crate's own enumeration is driven + // directly; anything the native layer reported goes back through it. + #[cfg(not(any(target_os = "ios", target_os = "android")))] + if transport_kind == JadeTransportKind::Serial + && jade_client_rs::serial::enumerate_devices() + .iter() + .any(|device| device.path == path) + { + return Ok(Arc::new(jade_client_rs::SerialTransport::open(path)?)); + } + + let callback = transport_callback().ok_or(JadeError::NotInitialized)?; + let open_path = path.to_string(); + let opener = Arc::clone(&callback); + let result = tokio::task::spawn_blocking(move || opener.open_device(open_path)) + .await + .map_err(|error| JadeError::IoError { + error_details: format!("open task failed: {error}"), + })?; + if !result.success { + return Err(JadeError::ConnectionError { + error_details: result.error, + }); + } + Ok(Arc::new(CallbackTransport::new(callback, path.to_string()))) + } + + /// Close the device and clear session state. + /// + /// Safe to call while an operation is in flight: the cancel handle closes + /// the transport without taking the session lock, so a blocked request + /// returns promptly instead of running out its deadline. + pub async fn disconnect(&self) -> Result<(), JadeError> { + self.connected.store(false, Ordering::SeqCst); + + if let Some(cancel) = self.cancel.write().await.take() { + if let Err(error) = cancel.cancel().await { + log::debug!("[jade] error closing the transport: {error}"); + } + } + *self.connected_device.write().await = None; + *self.session.lock().await = None; + Ok(()) + } + + /// Abort the operation in flight without tearing down session state. + /// + /// Jade has no cancel message, so closing the link is the only way to stop a + /// pending confirmation. The application is expected to reconnect. + pub async fn cancel(&self) -> Result<(), JadeError> { + let handle = self.cancel.read().await.clone(); + if let Some(handle) = handle { + handle.cancel().await?; + } + Ok(()) + } + + /// Record a disconnect the native layer noticed while nothing was in flight. + pub async fn notify_disconnected(&self, path: &str) { + let matches = self + .connected_device + .read() + .await + .as_ref() + .map(|device| device.path == path) + .unwrap_or(false); + if matches { + log::debug!("[jade] native layer reported a disconnect"); + let _ = self.disconnect().await; + } + } + + pub fn is_connected(&self) -> bool { + self.connected.load(Ordering::SeqCst) + } + + pub async fn connected_device(&self) -> Option { + self.connected_device.read().await.clone() + } + + /// The version summary read at connect, or refreshed since. + pub async fn version_info(&self) -> Option { + self.session + .lock() + .await + .as_ref() + .map(|session| session.version_info().clone()) + } + + /// Re-read the version summary from the device. + pub async fn refresh_version_info(&self) -> Result { + let mut guard = self.session.lock().await; + let session = guard.as_mut().ok_or(JadeError::NotConnected)?; + session.refresh_version_info().await.cloned() + } + + // ------------------------------------------------------------------ + // Operations + // ------------------------------------------------------------------ + + pub async fn ping(&self) -> Result { + let mut guard = self.session.lock().await; + guard.as_mut().ok_or(JadeError::NotConnected)?.ping().await + } + + pub async fn unlock(&self, network: JadeNetwork) -> Result<(), JadeError> { + let mut guard = self.session.lock().await; + guard + .as_mut() + .ok_or(JadeError::NotConnected)? + .unlock(network) + .await + } + + pub async fn logout(&self) -> Result<(), JadeError> { + let mut guard = self.session.lock().await; + guard + .as_mut() + .ok_or(JadeError::NotConnected)? + .logout() + .await + } + + pub async fn master_fingerprint(&self, network: JadeNetwork) -> Result { + let mut guard = self.session.lock().await; + guard + .as_mut() + .ok_or(JadeError::NotConnected)? + .master_fingerprint(network) + .await + } + + pub async fn get_xpub( + &self, + network: JadeNetwork, + derivation_path: String, + ) -> Result { + let mut guard = self.session.lock().await; + guard + .as_mut() + .ok_or(JadeError::NotConnected)? + .get_xpub(network, &derivation_path) + .await + } + + pub async fn account_export( + &self, + network: JadeNetwork, + account_index: u32, + account_types: Vec, + ) -> Result { + let variants: Vec = account_types + .into_iter() + .map(account_type_to_variant) + .collect(); + let mut guard = self.session.lock().await; + guard + .as_mut() + .ok_or(JadeError::NotConnected)? + .account_export(network, account_index, &variants) + .await + } + + pub async fn verify_address( + &self, + network: JadeNetwork, + variant: JadeAddressVariant, + derivation_path: String, + expected_address: String, + ) -> Result<(), JadeError> { + let mut guard = self.session.lock().await; + guard + .as_mut() + .ok_or(JadeError::NotConnected)? + .verify_address(network, variant, &derivation_path, &expected_address) + .await + } + + pub async fn sign_message( + &self, + network: JadeNetwork, + derivation_path: String, + message: String, + ) -> Result { + let mut guard = self.session.lock().await; + guard + .as_mut() + .ok_or(JadeError::NotConnected)? + .sign_message(network, &derivation_path, &message) + .await + } + + /// Sign a base64 PSBT and return the signed PSBT, base64 encoded. + /// + /// The FFI surface speaks base64 because that is what `compose_transaction` + /// emits and what `finalize_psbt` expects; the protocol crate works in typed + /// PSBTs, so the encoding boundary lives here. + pub async fn sign_psbt(&self, network: JadeNetwork, psbt: String) -> Result { + let bytes = STANDARD + .decode(psbt.trim()) + .map_err(|error| JadeError::InvalidPsbt { + error_details: format!("base64 decoding failed: {error}"), + })?; + let parsed = Psbt::deserialize(&bytes).map_err(|error| JadeError::InvalidPsbt { + error_details: format!("parsing failed: {error}"), + })?; + + let mut guard = self.session.lock().await; + let signed = guard + .as_mut() + .ok_or(JadeError::NotConnected)? + .sign_psbt(network, &parsed) + .await?; + Ok(STANDARD.encode(signed.serialize())) + } +} diff --git a/src/modules/jade/mod.rs b/src/modules/jade/mod.rs new file mode 100644 index 0000000..ce2800c --- /dev/null +++ b/src/modules/jade/mod.rs @@ -0,0 +1,29 @@ +//! Blockstream Jade hardware wallet integration. +//! +//! The protocol lives in the `jade-client-rs` crate. This module is the FFI +//! adapter: it attaches UniFFI scaffolding to that crate's types, exposes the +//! transport contract the native application implements, and owns the session +//! state a free-function FFI surface implies. +//! +//! One hard rule for anything added here: no `#[uniffi::export]` item may be +//! `cfg` gated. All three build scripts generate bindings from the host library +//! rather than the target one, so a host only export would appear in the +//! generated Swift and Kotlin while being absent from the device library. + +mod callbacks; +mod implementation; +#[cfg(test)] +mod tests; +mod types; + +pub use callbacks::{ + jade_set_transport_callback, JadeNativeDevice, JadeTransportCallback, JadeTransportReadResult, + JadeTransportResult, +}; +pub use implementation::JadeManager; +pub(crate) use types::account_type_to_variant; +pub use types::{ + JadeAccount, JadeAccountExport, JadeAddressVariant, JadeDeviceInfo, JadeError, JadeNetwork, + JadePingStatus, JadeSignedMessage, JadeState, JadeTransportErrorCode, JadeTransportKind, + JadeVersionInfo, JadeXpubResponse, +}; diff --git a/src/modules/jade/tests.rs b/src/modules/jade/tests.rs new file mode 100644 index 0000000..c955e96 --- /dev/null +++ b/src/modules/jade/tests.rs @@ -0,0 +1,188 @@ +//! Tests for the FFI adapter. +//! +//! Protocol level behaviour (framing, correlation, the unlock exchange, PSBT +//! checks) is tested in the `jade-client-rs` crate. What is left here is the +//! adapter: the account type mapping, and the bridge from the foreign callback +//! onto the crate's transport trait. + +use super::callbacks::{ + CallbackTransport, JadeNativeDevice, JadeTransportCallback, JadeTransportReadResult, + JadeTransportResult, +}; +use super::types::{account_type_to_variant, JadeAddressVariant, JadeTransportKind}; +use crate::onchain::AccountType; +use jade_client_rs::{JadeError, JadeTransport, MAX_CHUNK_BYTES}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +#[test] +fn account_types_map_to_descriptor_variants() { + let cases = [ + (AccountType::Legacy, JadeAddressVariant::Pkh), + (AccountType::WrappedSegwit, JadeAddressVariant::ShWpkh), + (AccountType::NativeSegwit, JadeAddressVariant::Wpkh), + (AccountType::Taproot, JadeAddressVariant::Tr), + ]; + for (account_type, expected) in cases { + assert_eq!(account_type_to_variant(account_type), expected); + } +} + +/// A callback that records what it was asked to do. +struct MockCallback { + chunk_size: u32, + writes: Mutex>>, + reads: Mutex>>, + fail_write: bool, +} + +impl MockCallback { + fn with_chunk_size(chunk_size: u32) -> Arc { + Arc::new(Self { + chunk_size, + writes: Mutex::new(Vec::new()), + reads: Mutex::new(Vec::new()), + fail_write: false, + }) + } + + fn failing() -> Arc { + Arc::new(Self { + chunk_size: 64, + writes: Mutex::new(Vec::new()), + reads: Mutex::new(Vec::new()), + fail_write: true, + }) + } +} + +impl JadeTransportCallback for MockCallback { + fn scan_devices(&self, _timeout_ms: u32) -> Vec { + vec![JadeNativeDevice { + path: "AA:BB:CC:DD:EE:FF".to_string(), + transport: JadeTransportKind::Bluetooth, + name: Some("Jade C0FFEE".to_string()), + serial_number: Some("C0FFEE".to_string()), + }] + } + + fn open_device(&self, _path: String) -> JadeTransportResult { + JadeTransportResult { + success: true, + error: String::new(), + error_code: None, + } + } + + fn close_device(&self, _path: String) -> JadeTransportResult { + JadeTransportResult { + success: true, + error: String::new(), + error_code: None, + } + } + + fn write_chunk(&self, _path: String, data: Vec) -> JadeTransportResult { + if self.fail_write { + return JadeTransportResult { + success: false, + error: "device went away".to_string(), + error_code: Some(jade_client_rs::JadeTransportErrorCode::Disconnected), + }; + } + self.writes.lock().unwrap().push(data); + JadeTransportResult { + success: true, + error: String::new(), + error_code: None, + } + } + + fn read_chunk(&self, _path: String, _timeout_ms: u32) -> JadeTransportReadResult { + let data = self.reads.lock().unwrap().pop().unwrap_or_default(); + JadeTransportReadResult { + success: true, + data, + error: String::new(), + error_code: None, + } + } + + fn get_chunk_size(&self, _path: String) -> u32 { + self.chunk_size + } +} + +#[tokio::test] +async fn writes_are_split_at_the_reported_chunk_size() { + let callback = MockCallback::with_chunk_size(4); + let transport = CallbackTransport::new(Arc::clone(&callback) as Arc<_>, "path".to_string()); + + transport + .write_all(vec![1, 2, 3, 4, 5, 6, 7, 8, 9]) + .await + .unwrap(); + + let writes = callback.writes.lock().unwrap(); + assert_eq!(writes.len(), 3); + assert_eq!(writes[0], vec![1, 2, 3, 4]); + assert_eq!(writes[1], vec![5, 6, 7, 8]); + assert_eq!(writes[2], vec![9]); +} + +#[tokio::test] +async fn a_zero_chunk_size_does_not_stall_the_write_loop() { + // A native implementation can report 0 before the MTU is negotiated. + // Without clamping, chunks(0) panics and the loop never advances. + let callback = MockCallback::with_chunk_size(0); + let transport = CallbackTransport::new(Arc::clone(&callback) as Arc<_>, "path".to_string()); + + transport.write_all(vec![1, 2, 3]).await.unwrap(); + + let writes = callback.writes.lock().unwrap(); + assert_eq!( + writes.len(), + 3, + "a clamped size of 1 sends one byte per write" + ); +} + +#[tokio::test] +async fn an_oversized_chunk_size_is_capped_to_the_bluetooth_limit() { + let callback = MockCallback::with_chunk_size(100_000); + let transport = CallbackTransport::new(Arc::clone(&callback) as Arc<_>, "path".to_string()); + + let payload = vec![7u8; MAX_CHUNK_BYTES as usize + 10]; + transport.write_all(payload).await.unwrap(); + + let writes = callback.writes.lock().unwrap(); + assert_eq!(writes.len(), 2); + assert_eq!(writes[0].len(), MAX_CHUNK_BYTES as usize); + assert_eq!(writes[1].len(), 10); +} + +#[tokio::test] +async fn a_typed_transport_error_survives_the_bridge() { + // The trezor adapter has to encode its error code into a string and parse it + // back out, because its upstream crate offers no typed channel. This one + // carries the code the whole way, so the mapping is exact. + let callback = MockCallback::failing(); + let transport = CallbackTransport::new(callback as Arc<_>, "path".to_string()); + + let error = transport.write_all(vec![1]).await.unwrap_err(); + assert_eq!(error, JadeError::DeviceDisconnected); +} + +#[tokio::test] +async fn an_empty_read_is_not_an_error() { + // Success with no data means "nothing yet", which is the normal state while + // the user is deciding on the device. + let callback = MockCallback::with_chunk_size(64); + let transport = CallbackTransport::new(callback as Arc<_>, "path".to_string()); + + let data = transport + .read_some(Duration::from_millis(10)) + .await + .unwrap(); + assert!(data.is_empty()); +} diff --git a/src/modules/jade/types.rs b/src/modules/jade/types.rs new file mode 100644 index 0000000..fdac7f1 --- /dev/null +++ b/src/modules/jade/types.rs @@ -0,0 +1,156 @@ +//! UniFFI scaffolding for the `jade-client-rs` types. +//! +//! Every type here is defined in that crate, not this one. `#[uniffi::remote]` +//! attaches the same scaffolding `#[derive(uniffi::…)]` would, without a +//! mirrored set of structs and hand-written `From` conversions in both +//! directions. The trezor module predates this and pays that cost; this module +//! does not. +//! +//! The declarations below must match the upstream definitions variant for +//! variant and field for field. The compiler catches a mismatch, and the tests +//! in `tests.rs` exercise the round trip. + +pub use jade_client_rs::{ + JadeAccount, JadeAccountExport, JadeAddressVariant, JadeDeviceInfo, JadeError, JadeNetwork, + JadePingStatus, JadeSignedMessage, JadeState, JadeTransportErrorCode, JadeTransportKind, + JadeVersionInfo, JadeXpubResponse, +}; + +use crate::onchain::AccountType; + +#[uniffi::remote(Enum)] +pub enum JadeNetwork { + Mainnet, + Testnet, + Regtest, +} + +#[uniffi::remote(Enum)] +pub enum JadeTransportKind { + Bluetooth, + Serial, +} + +#[uniffi::remote(Enum)] +pub enum JadeAddressVariant { + Pkh, + Wpkh, + ShWpkh, + Tr, +} + +#[uniffi::remote(Enum)] +pub enum JadeState { + Uninit, + Unsaved, + Locked, + Ready, + Temp, + Unknown, +} + +#[uniffi::remote(Enum)] +pub enum JadePingStatus { + Idle, + Busy, + AwaitingUserInput, +} + +#[uniffi::remote(Enum)] +pub enum JadeTransportErrorCode { + DeviceBusy, + NotConnected, + Disconnected, + Timeout, + PermissionDenied, +} + +#[uniffi::remote(Record)] +pub struct JadeDeviceInfo { + pub path: String, + pub transport: JadeTransportKind, + pub name: Option, + pub serial_number: Option, +} + +#[uniffi::remote(Record)] +pub struct JadeVersionInfo { + pub jade_version: String, + pub jade_state: JadeState, + pub jade_networks: Option, + pub jade_has_pin: Option, + pub board_type: Option, + pub jade_config: Option, + pub jade_features: Option, + pub idf_version: Option, + pub chip_features: Option, + pub efuse_mac: Option, + pub battery_status: Option, + pub jade_ota_max_chunk: Option, +} + +#[uniffi::remote(Record)] +pub struct JadeXpubResponse { + pub xpub: String, + pub derivation_path: String, + pub master_fingerprint: String, +} + +#[uniffi::remote(Record)] +pub struct JadeAccount { + pub variant: JadeAddressVariant, + pub xpub: String, + pub derivation_path: String, +} + +#[uniffi::remote(Record)] +pub struct JadeAccountExport { + pub master_fingerprint: String, + pub account_index: u32, + pub accounts: Vec, +} + +#[uniffi::remote(Record)] +pub struct JadeSignedMessage { + pub signature: String, + pub address: String, + pub derivation_path: String, +} + +#[uniffi::remote(Error)] +pub enum JadeError { + TransportError { error_details: String }, + DeviceNotFound, + DeviceDisconnected, + DeviceBusy, + NotConnected, + NotInitialized, + ConnectionError { error_details: String }, + ProtocolError { error_details: String }, + Timeout, + UserCancelled, + DeviceLocked, + DeviceUninitialized, + InvalidPin, + NetworkMismatch { error_details: String }, + UnsupportedFirmware { installed: String, required: String }, + InvalidPath { error_details: String }, + InvalidPsbt { error_details: String }, + PsbtTooLarge { size: u64, max: u64 }, + FingerprintMismatch { device: String, psbt: String }, + NothingSigned, + AddressMismatch { expected: String, returned: String }, + PinServerError { error_details: String }, + DeviceError { error_details: String }, + IoError { error_details: String }, +} + +/// Map the signer-neutral account type onto Jade's descriptor variant. +pub(crate) fn account_type_to_variant(account_type: AccountType) -> JadeAddressVariant { + match account_type { + AccountType::Legacy => JadeAddressVariant::Pkh, + AccountType::WrappedSegwit => JadeAddressVariant::ShWpkh, + AccountType::NativeSegwit => JadeAddressVariant::Wpkh, + AccountType::Taproot => JadeAddressVariant::Tr, + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 3872dc2..33dc643 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -2,6 +2,7 @@ pub mod activity; pub mod blocktank; pub mod boltz; pub mod hardware_wallet; +pub mod jade; pub mod lnurl; pub mod onchain; pub mod pubky;