Skip to main content

bhwi_async_transport/
lib.rs

1//! Native discovery and I/O for asynchronous hardware-wallet sessions.
2//!
3//! [`NativeSource`] discovers device types enabled by Cargo features, using HID,
4//! serial ports, native USB, and optional emulator probes. Compiled support does
5//! not imply that a device is attached or accessible. The `webusb` backend uses
6//! `nusb`, not the browser WebUSB API.
7//!
8//! Shared discovery types and [`Info`] are reexported from [`bhwi_async`].
9
10use async_trait::async_trait;
11use futures::future::join_all;
12
13#[cfg(feature = "bitbox")]
14use crate::bitbox::BitBoxDevice;
15#[cfg(feature = "coldcard")]
16use crate::coldcard::ColdcardDevice;
17#[cfg(feature = "jade")]
18use crate::jade::JadeDevice;
19#[cfg(feature = "keepkey")]
20use crate::keepkey::KeepKeyDevice;
21#[cfg(feature = "ledger")]
22use crate::ledger::LedgerDevice;
23#[cfg(feature = "specter")]
24use crate::specter::SpecterDevice;
25#[cfg(feature = "trezor")]
26use crate::trezor::TrezorDevice;
27
28#[cfg(feature = "bitbox")]
29pub mod bitbox;
30#[cfg(feature = "coldcard")]
31pub mod coldcard;
32#[cfg(any(feature = "keepkey", feature = "trezor"))]
33pub mod emulator;
34pub mod error;
35#[cfg(any(
36    feature = "bitbox",
37    feature = "coldcard",
38    feature = "keepkey",
39    feature = "ledger",
40    feature = "trezor"
41))]
42pub mod hid;
43#[cfg(feature = "jade")]
44pub mod jade;
45#[cfg(feature = "keepkey")]
46pub mod keepkey;
47#[cfg(feature = "ledger")]
48pub mod ledger;
49#[cfg(any(feature = "jade", feature = "specter"))]
50mod serial;
51#[cfg(feature = "specter")]
52pub mod specter;
53#[cfg(feature = "trezor")]
54pub mod trezor;
55#[cfg(target_os = "linux")]
56pub mod udev;
57#[cfg(any(feature = "keepkey", feature = "trezor"))]
58pub mod webusb;
59
60pub use bhwi_async::Info;
61pub use bhwi_async::device::{
62    Device, DeviceCandidate, DeviceManager, DeviceScan, DeviceSelector, DeviceSource, DeviceType,
63    HostInteractionFactory, NoUsableDevice, PairingCodePrompt, ScanEntry, SelectError,
64    SkippedDevice, can_sign_taproot, classify_error, is_user_cancelled, networks_string, no_device,
65    reports_device_info,
66};
67pub use error::{NativeError, NativeResult};
68
69/// A native device-discovery source with optional broad-scan exclusions.
70///
71/// The default scans all compiled device types. Exclusions apply only when
72/// [`DeviceSelector::device_type`] is unset; explicitly naming a type still
73/// selects it. Listing can probe emulators when requested by the selector.
74/// If any candidates are found, listing ignores failures from other device
75/// types; otherwise it returns the first discovery error, if any.
76#[derive(Default)]
77pub struct NativeSource {
78    excluded: Vec<DeviceType>,
79}
80
81impl NativeSource {
82    /// Creates a source that omits `excluded` types from broad discovery.
83    pub fn excluding(excluded: impl IntoIterator<Item = DeviceType>) -> Self {
84        Self {
85            excluded: excluded.into_iter().collect(),
86        }
87    }
88
89    /// Returns whether support for `device_type` was compiled into this build.
90    ///
91    /// This does not check for attached devices, permissions, or usable wallets.
92    pub fn is_supported(device_type: DeviceType) -> bool {
93        match device_type {
94            DeviceType::BitBox02 => cfg!(feature = "bitbox"),
95            DeviceType::Coldcard => cfg!(feature = "coldcard"),
96            DeviceType::Jade => cfg!(feature = "jade"),
97            DeviceType::KeepKey => cfg!(feature = "keepkey"),
98            DeviceType::Ledger => cfg!(feature = "ledger"),
99            DeviceType::Specter => cfg!(feature = "specter"),
100            DeviceType::Trezor => cfg!(feature = "trezor"),
101        }
102    }
103
104    fn walked(&self, selector: &DeviceSelector) -> Vec<DeviceType> {
105        match selector.device_type {
106            Some(device_type) => vec![device_type],
107            None => DeviceType::ALL
108                .into_iter()
109                .filter(|d| Self::is_supported(*d) && !self.excluded.contains(d))
110                .collect(),
111        }
112    }
113
114    async fn list_device_type(
115        device_type: DeviceType,
116        selector: &DeviceSelector,
117    ) -> NativeResult<Vec<DeviceCandidate>> {
118        Ok(match device_type {
119            #[cfg(feature = "bitbox")]
120            DeviceType::BitBox02 => BitBoxDevice::list(selector).await?,
121            #[cfg(feature = "ledger")]
122            DeviceType::Ledger => LedgerDevice::list(selector).await?,
123            #[cfg(feature = "coldcard")]
124            DeviceType::Coldcard => ColdcardDevice::list(selector).await?,
125            #[cfg(feature = "jade")]
126            DeviceType::Jade => JadeDevice::list(selector).await?,
127            #[cfg(feature = "keepkey")]
128            DeviceType::KeepKey => KeepKeyDevice::list(selector).await?,
129            #[cfg(feature = "specter")]
130            DeviceType::Specter => SpecterDevice::list(selector).await?,
131            #[cfg(feature = "trezor")]
132            DeviceType::Trezor => TrezorDevice::list(selector).await?,
133            #[allow(unreachable_patterns)]
134            _ => return Err(NativeError::NotCompiled(device_type)),
135        })
136    }
137}
138
139#[async_trait(?Send)]
140impl DeviceSource for NativeSource {
141    type Error = NativeError;
142
143    async fn list(&self, selector: &DeviceSelector) -> NativeResult<Vec<DeviceCandidate>> {
144        let results = join_all(
145            self.walked(selector)
146                .into_iter()
147                .map(|device_type| Self::list_device_type(device_type, selector)),
148        )
149        .await;
150        collect_candidates(results)
151    }
152
153    async fn open(
154        &self,
155        candidate: &DeviceCandidate,
156        selector: &DeviceSelector,
157        pairing_code: Option<&PairingCodePrompt>,
158        host_interaction: Option<&HostInteractionFactory>,
159    ) -> NativeResult<Device> {
160        match candidate.device_type {
161            #[cfg(feature = "bitbox")]
162            DeviceType::BitBox02 => {
163                BitBoxDevice::open(candidate, selector, pairing_code, host_interaction).await
164            }
165            #[cfg(feature = "ledger")]
166            DeviceType::Ledger => {
167                LedgerDevice::open(candidate, selector, pairing_code, host_interaction).await
168            }
169            #[cfg(feature = "coldcard")]
170            DeviceType::Coldcard => {
171                ColdcardDevice::open(candidate, selector, pairing_code, host_interaction).await
172            }
173            #[cfg(feature = "jade")]
174            DeviceType::Jade => {
175                JadeDevice::open(candidate, selector, pairing_code, host_interaction).await
176            }
177            #[cfg(feature = "keepkey")]
178            DeviceType::KeepKey => {
179                KeepKeyDevice::open(candidate, selector, pairing_code, host_interaction).await
180            }
181            #[cfg(feature = "specter")]
182            DeviceType::Specter => {
183                SpecterDevice::open(candidate, selector, pairing_code, host_interaction).await
184            }
185            #[cfg(feature = "trezor")]
186            DeviceType::Trezor => {
187                TrezorDevice::open(candidate, selector, pairing_code, host_interaction).await
188            }
189            #[allow(unreachable_patterns)]
190            device_type => Err(NativeError::NotCompiled(device_type)),
191        }
192    }
193}
194
195/// One bus failing does not hide what the others found; finding nothing reports why.
196fn collect_candidates(
197    results: Vec<NativeResult<Vec<DeviceCandidate>>>,
198) -> NativeResult<Vec<DeviceCandidate>> {
199    let mut candidates = Vec::new();
200    let mut first_error = None;
201    for result in results {
202        match result {
203            Ok(found) => candidates.extend(found),
204            Err(err) if first_error.is_none() => first_error = Some(err),
205            Err(_) => {}
206        }
207    }
208    if candidates.is_empty()
209        && let Some(err) = first_error
210    {
211        return Err(err);
212    }
213    Ok(candidates)
214}
215
216/// A selected path names one backend, so the other bus is not walked at all.
217#[cfg(any(feature = "keepkey", feature = "trezor"))]
218pub(crate) fn uses_backend(selected_path: Option<&str>, prefix: &str) -> bool {
219    selected_path.is_none_or(|path| path.starts_with(prefix))
220}
221
222/// Native discovery and opening for one device type.
223///
224/// Futures need not be `Send`. Implementations may use different native I/O
225/// backends and emulator probes.
226#[async_trait(?Send)]
227pub trait DeviceEnumerator {
228    /// Lists candidates matching the selector without opening hardware devices.
229    ///
230    /// Emulator discovery may connect or send a probe; a listed candidate is not
231    /// guaranteed to remain available or contain the requested wallet.
232    async fn list(selector: &DeviceSelector) -> NativeResult<Vec<DeviceCandidate>>;
233
234    /// Opens a candidate using the selector's device-specific session options.
235    ///
236    /// Opening may perform protocol I/O, such as a Specter fingerprint probe.
237    /// Pairing and host-interaction callbacks are used only by backends that
238    /// support them. Opening does not generally unlock the device or verify the
239    /// selector's fingerprint.
240    async fn open(
241        candidate: &DeviceCandidate,
242        selector: &DeviceSelector,
243        pairing_code: Option<&PairingCodePrompt>,
244        host_interaction: Option<&HostInteractionFactory>,
245    ) -> NativeResult<Device>;
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    fn candidate(path: &str) -> DeviceCandidate {
253        DeviceCandidate {
254            device_type: DeviceType::Coldcard,
255            name: "Coldcard".to_owned(),
256            model: "coldcard".to_owned(),
257            path: path.to_owned(),
258            is_emulated: false,
259        }
260    }
261
262    fn error(message: &'static str) -> NativeError {
263        NativeError::MissingDeviceId(message)
264    }
265
266    #[cfg(feature = "specter")]
267    #[test]
268    fn an_excluded_device_is_skipped_only_when_not_named() {
269        let source = NativeSource::excluding([DeviceType::Specter]);
270        assert!(
271            !source
272                .walked(&DeviceSelector::default())
273                .contains(&DeviceType::Specter)
274        );
275        assert!(
276            NativeSource::default()
277                .walked(&DeviceSelector::default())
278                .contains(&DeviceType::Specter)
279        );
280        let named = DeviceSelector {
281            device_type: Some(DeviceType::Specter),
282            ..DeviceSelector::default()
283        };
284        assert_eq!(source.walked(&named), vec![DeviceType::Specter]);
285    }
286
287    #[test]
288    fn a_list_keeps_what_the_other_buses_found() {
289        let results = vec![
290            Ok(vec![candidate("hid:1")]),
291            Err(error("unrelated")),
292            Ok(vec![candidate("hid:2")]),
293        ];
294
295        let found = collect_candidates(results).expect("one bus failing is tolerated");
296        assert_eq!(found.len(), 2);
297    }
298
299    #[test]
300    fn a_list_that_found_nothing_reports_the_first_failure() {
301        let results = vec![Err(error("first")), Err(error("second"))];
302
303        let err = collect_candidates(results).expect_err("a list that found nothing reports why");
304        assert_eq!(err.to_string(), "first");
305    }
306
307    #[test]
308    fn one_bus_failing_alone_reports_the_failure() {
309        let results = vec![Err(error("path failed"))];
310
311        let err = collect_candidates(results).expect_err("a list that found nothing reports why");
312        assert_eq!(err.to_string(), "path failed");
313    }
314
315    #[test]
316    fn a_list_that_found_something_ignores_the_failure() {
317        let results = vec![Err(error("unrelated")), Ok(vec![candidate("hid:1")])];
318
319        let found = collect_candidates(results).expect("a found device wins over an error");
320        assert_eq!(found.len(), 1);
321    }
322}