Skip to main content

bhwi_async/
device.rs

1use core::fmt;
2use std::rc::Rc;
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use bhwi::bitcoin::{Network, bip32::Fingerprint};
7use bhwi::common::{self, ErrorKind};
8#[cfg(any(feature = "trezor", feature = "keepkey"))]
9use bhwi::passphrase::HostPassphrase;
10
11use crate::{ErrorKindOf, HWIDevice, HWIDeviceError, Info};
12
13/// A supported hardware-wallet family, independent of enabled backend features.
14#[derive(Debug, Clone, Copy, Eq, PartialEq)]
15pub enum DeviceType {
16    /// A BitBox02.
17    BitBox02,
18    /// A Coldcard.
19    Coldcard,
20    /// A Blockstream Jade.
21    Jade,
22    /// A KeepKey.
23    KeepKey,
24    /// A Ledger running the Bitcoin application.
25    Ledger,
26    /// A Specter-DIY.
27    Specter,
28    /// A Trezor.
29    Trezor,
30}
31
32impl DeviceType {
33    /// All recognized wallet families, including backends not compiled in.
34    pub const ALL: [DeviceType; 7] = [
35        DeviceType::BitBox02,
36        DeviceType::Coldcard,
37        DeviceType::Jade,
38        DeviceType::KeepKey,
39        DeviceType::Ledger,
40        DeviceType::Specter,
41        DeviceType::Trezor,
42    ];
43
44    /// Returns the lowercase device-family identifier.
45    pub fn as_str(self) -> &'static str {
46        match self {
47            DeviceType::BitBox02 => "bitbox02",
48            DeviceType::Coldcard => "coldcard",
49            DeviceType::Jade => "jade",
50            DeviceType::KeepKey => "keepkey",
51            DeviceType::Ledger => "ledger",
52            DeviceType::Specter => "specter",
53            DeviceType::Trezor => "trezor",
54        }
55    }
56}
57
58impl fmt::Display for DeviceType {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        f.write_str(self.as_str())
61    }
62}
63
64/// Returns whether the family accepts multisig descriptors directly at display time.
65pub fn supports_multisig_display_address(device_type: DeviceType) -> bool {
66    matches!(
67        device_type,
68        DeviceType::Coldcard | DeviceType::Jade | DeviceType::KeepKey | DeviceType::Trezor
69    )
70}
71
72/// Returns the known Taproot-signing capability for a family and model string.
73pub fn can_sign_taproot(device_type: DeviceType, model: &str) -> bool {
74    match device_type {
75        DeviceType::BitBox02 => false,
76        DeviceType::Ledger => true,
77        DeviceType::Jade => false,
78        DeviceType::KeepKey => false,
79        DeviceType::Coldcard => model.contains("edge"),
80        DeviceType::Specter => false,
81        DeviceType::Trezor => model != "trezor_one",
82    }
83}
84
85/// Returns whether the family reports the management metadata used by discovery.
86pub fn reports_device_info(device_type: DeviceType) -> bool {
87    matches!(device_type, DeviceType::KeepKey | DeviceType::Trezor)
88}
89
90/// Filters discovery candidates and optionally selects a wallet by fingerprint.
91#[derive(Debug, Clone)]
92pub struct DeviceSelector {
93    /// The network used when opening a device and running its unlock handshake.
94    pub network: Network,
95    /// A wallet fingerprint to query after the unlock handshake; `None` accepts any.
96    pub fingerprint: Option<Fingerprint>,
97    /// A device-family filter; `None` accepts any family.
98    pub device_type: Option<DeviceType>,
99    /// An exact discovery-path filter; `None` accepts any path.
100    pub device_path: Option<String>,
101    /// Whether discovery should include emulator candidates.
102    pub include_emulators: bool,
103    /// An optional host passphrase, supported only by Trezor and KeepKey.
104    #[cfg(any(feature = "trezor", feature = "keepkey"))]
105    pub passphrase: Option<HostPassphrase>,
106}
107
108impl Default for DeviceSelector {
109    fn default() -> Self {
110        Self {
111            network: Network::Bitcoin,
112            fingerprint: None,
113            device_type: None,
114            device_path: None,
115            include_emulators: false,
116            #[cfg(any(feature = "trezor", feature = "keepkey"))]
117            passphrase: None,
118        }
119    }
120}
121
122impl DeviceSelector {
123    /// Returns whether the family and path match, without probing a fingerprint.
124    ///
125    /// This does not check emulator inclusion, the network, or the passphrase.
126    pub fn matches(&self, device_type: DeviceType, path: &str) -> bool {
127        self.device_type.is_none_or(|target| target == device_type)
128            && self
129                .device_path
130                .as_ref()
131                .is_none_or(|target| target == path)
132    }
133}
134
135/// A callback that presents a BitBox02 pairing code to the user.
136pub type PairingCodePrompt = Arc<dyn Fn(&str) + Send + Sync>;
137
138/// A device found on a bus but not yet opened.
139#[derive(Debug, Clone)]
140pub struct DeviceCandidate {
141    /// The device family.
142    pub device_type: DeviceType,
143    /// The discovery source's display name.
144    pub name: String,
145    /// The discovery source's model identifier.
146    pub model: String,
147    /// The source-specific path used to open this candidate.
148    pub path: String,
149    /// Whether this candidate represents an emulator.
150    pub is_emulated: bool,
151}
152
153/// Creates host-input handlers before a device is boxed, including mid-command prompts.
154pub type HostInteractionFactory = Rc<dyn Fn() -> Box<dyn crate::HostInteraction>>;
155
156/// Discovers and opens devices using source-specific I/O.
157///
158/// Futures need not be `Send`; implementations choose their I/O runtime.
159#[async_trait(?Send)]
160pub trait DeviceSource {
161    /// A discovery or device-opening failure.
162    type Error: std::error::Error + ErrorKindOf + 'static;
163
164    /// Lists matching candidates, potentially probing emulators with I/O.
165    async fn list(&self, selector: &DeviceSelector) -> Result<Vec<DeviceCandidate>, Self::Error>;
166
167    /// Opens a candidate and attaches the supplied pairing and host-input callbacks.
168    ///
169    /// Opening may perform I/O, but does not by itself promise an unlocked wallet.
170    async fn open(
171        &self,
172        candidate: &DeviceCandidate,
173        selector: &DeviceSelector,
174        pairing_code: Option<&PairingCodePrompt>,
175        host_interaction: Option<&HostInteractionFactory>,
176    ) -> Result<Device, Self::Error>;
177}
178
179/// A discovery, opening, or wallet-selection failure.
180#[derive(Debug, thiserror::Error)]
181pub enum SelectError<E> {
182    /// The source could not list candidates.
183    #[error(transparent)]
184    Source(
185        /// The source error.
186        E,
187    ),
188
189    /// A wallet operation failed or was cancelled.
190    #[error(transparent)]
191    Device(
192        /// The wallet-operation error.
193        #[from]
194        HWIDeviceError,
195    ),
196
197    /// Candidates were found but none could be used.
198    #[error(transparent)]
199    NoUsableDevice(
200        /// The rejected candidates and their errors.
201        #[from]
202        NoUsableDevice,
203    ),
204
205    /// A host passphrase was supplied for a BitBox02.
206    #[cfg(feature = "bitbox")]
207    #[error("{}", crate::bitbox::HOST_PASSPHRASE_REJECTED)]
208    HostPassphraseRejected,
209}
210
211/// Discovers, opens, and selects wallets through a [`DeviceSource`].
212pub struct DeviceManager<S> {
213    /// The discovery filters and wallet-selection requirements.
214    pub selector: DeviceSelector,
215    source: S,
216    pairing_code: Option<PairingCodePrompt>,
217    host_interaction: Option<HostInteractionFactory>,
218}
219
220impl<S: DeviceSource> DeviceManager<S> {
221    /// Creates a manager with no pairing or host-input callbacks.
222    pub fn new(source: S, selector: DeviceSelector) -> Self {
223        Self {
224            selector,
225            source,
226            pairing_code: None,
227            host_interaction: None,
228        }
229    }
230
231    /// Sets the callback used to present BitBox02 pairing codes.
232    pub fn with_pairing_code_prompt(mut self, prompt: PairingCodePrompt) -> Self {
233        self.pairing_code = Some(prompt);
234        self
235    }
236
237    /// Sets the factory used to attach host-input handlers to opened devices.
238    pub fn with_host_interaction(mut self, factory: HostInteractionFactory) -> Self {
239        self.host_interaction = Some(factory);
240        self
241    }
242
243    /// Lists candidates using the selector, potentially probing emulators.
244    pub async fn list(&self) -> Result<Vec<DeviceCandidate>, S::Error> {
245        self.source.list(&self.selector).await
246    }
247
248    /// Opens a candidate using the selector and configured callbacks.
249    pub async fn open(&self, candidate: &DeviceCandidate) -> Result<Device, S::Error> {
250        self.source
251            .open(
252                candidate,
253                &self.selector,
254                self.pairing_code.as_ref(),
255                self.host_interaction.as_ref(),
256            )
257            .await
258    }
259
260    /// Opens listed candidates without additional unlock or metadata requests.
261    ///
262    /// Source-specific opening may still send wallet commands, such as native
263    /// Specter's fingerprint probe. Opening errors are collected in
264    /// [`DeviceScan::skipped`].
265    pub async fn enumerate(&self) -> Result<DeviceScan, S::Error> {
266        let mut scan = DeviceScan::default();
267        for candidate in self.list().await? {
268            match self.open(&candidate).await {
269                Ok(device) => scan.devices.push(device),
270                Err(err) => scan.skipped.push(unopened_candidate(&candidate, &err)),
271            }
272        }
273        Ok(scan)
274    }
275
276    /// Opens candidates and runs their unlock handshake until the optional fingerprint matches.
277    ///
278    /// Successful Trezor or KeepKey initialization can leave the wallet PIN-locked.
279    /// A common [`bhwi::common::ErrorKind::UserCancelled`] from unlock or fingerprint
280    /// queries aborts selection. Without a fingerprint filter, any unlock failure
281    /// is returned rather than silently selecting another wallet. Other failures
282    /// are collected as skipped devices. Trezor and KeepKey device-side cancellations
283    /// become authentication refusals and may be skipped when filtering by fingerprint.
284    pub async fn select(
285        &self,
286    ) -> Result<(Option<Device>, Vec<SkippedDevice>), SelectError<S::Error>> {
287        let candidates = self.list().await.map_err(SelectError::Source)?;
288        let mut skipped = Vec::new();
289        let mut target_dev = None;
290        for candidate in candidates {
291            let mut d = match self.open(&candidate).await {
292                Ok(device) => device,
293                Err(err) => {
294                    skipped.push(unopened_candidate(&candidate, &err));
295                    continue;
296                }
297            };
298
299            if let Err(err) = d.device().unlock(self.selector.network).await {
300                // Without a fingerprint, skipping would hand back the next wallet instead.
301                if err.kind() == Some(ErrorKind::UserCancelled)
302                    || self.selector.fingerprint.is_none()
303                {
304                    return Err(err.into());
305                }
306                skipped.push(skipped_candidate(&candidate, &err));
307                continue;
308            }
309
310            let Some(fingerprint) = self.selector.fingerprint else {
311                target_dev = Some(d);
312                break;
313            };
314            match d.fingerprint().await {
315                Ok(found) if found == fingerprint => {
316                    target_dev = Some(d);
317                    break;
318                }
319                Ok(_) => {}
320                Err(err) => {
321                    if err.kind() == Some(ErrorKind::UserCancelled) {
322                        return Err(err.into());
323                    }
324                    skipped.push(skipped_candidate(&candidate, &err));
325                }
326            }
327        }
328        let Some(dev) = target_dev else {
329            return Ok((None, skipped));
330        };
331        #[cfg(all(feature = "bitbox", any(feature = "trezor", feature = "keepkey")))]
332        if self.selector.passphrase.is_some() && dev.device_type() == DeviceType::BitBox02 {
333            return Err(SelectError::HostPassphraseRejected);
334        }
335        Ok((Some(dev), skipped))
336    }
337
338    /// Opens and probes candidates, caching information and initialized-wallet fingerprints.
339    ///
340    /// A common [`bhwi::common::ErrorKind::UserCancelled`] from unlock or probe commands
341    /// aborts the scan. Other per-device failures, including Trezor and KeepKey
342    /// device-side cancellations mapped to authentication refusals, are collected
343    /// as skipped devices.
344    pub async fn scan(&self) -> Result<DeviceScan, SelectError<S::Error>> {
345        let candidates = self.list().await.map_err(SelectError::Source)?;
346        let mut scan = DeviceScan::default();
347        for candidate in candidates {
348            let mut device = match self.open(&candidate).await {
349                Ok(device) => device,
350                Err(err) => {
351                    scan.skipped.push(unopened_candidate(&candidate, &err));
352                    continue;
353                }
354            };
355            match probe(&mut device, self.selector.network).await {
356                Ok(()) => scan.devices.push(device),
357                Err(err) if err.kind() == Some(ErrorKind::UserCancelled) => return Err(err.into()),
358                Err(err) => scan.skipped.push(skipped_candidate(&candidate, &err)),
359            }
360        }
361        Ok(scan)
362    }
363
364    /// Selects a wallet, returning an error if all candidates were skipped.
365    ///
366    /// With no fingerprint filter, the first successful unlock handshake is accepted;
367    /// Trezor and KeepKey may still require a PIN. Uses the rules of [`Self::select`].
368    pub async fn get_device_with_fingerprint(
369        &self,
370    ) -> Result<Option<Device>, SelectError<S::Error>> {
371        let (device, skipped) = self.select().await?;
372        match device {
373            Some(device) => Ok(Some(device)),
374            None => Ok(no_device(skipped)?),
375        }
376    }
377
378    /// Returns the first usable candidate from [`Self::enumerate`].
379    ///
380    /// The manager makes no additional unlock or metadata request. Discovery and
381    /// source-specific opening may still perform protocol I/O, including native
382    /// Specter's fingerprint probe.
383    pub async fn get_device_without_contacting(
384        &self,
385    ) -> Result<Option<Device>, SelectError<S::Error>> {
386        let scan = self.enumerate().await.map_err(SelectError::Source)?;
387        match scan.devices.into_iter().next() {
388            Some(device) => Ok(Some(device)),
389            None => Ok(no_device(scan.skipped)?),
390        }
391    }
392}
393
394/// A scan in which every candidate was rejected.
395#[derive(Debug)]
396pub struct NoUsableDevice {
397    /// The rejected candidates and their classified errors.
398    pub skipped: Vec<SkippedDevice>,
399}
400
401impl fmt::Display for NoUsableDevice {
402    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
403        let reasons = self
404            .skipped
405            .iter()
406            .map(|s| format!("{} at {}: {}", s.device_type, s.path, s.error))
407            .collect::<Vec<_>>()
408            .join("; ");
409        write!(f, "no usable device found, could not open: {reasons}")
410    }
411}
412
413impl std::error::Error for NoUsableDevice {}
414
415fn skipped_candidate(
416    candidate: &DeviceCandidate,
417    error: &(dyn std::error::Error + 'static),
418) -> SkippedDevice {
419    SkippedDevice::new(
420        candidate.device_type,
421        &candidate.model,
422        &candidate.path,
423        error,
424    )
425}
426
427fn unopened_candidate<E>(candidate: &DeviceCandidate, error: &E) -> SkippedDevice
428where
429    E: std::error::Error + ErrorKindOf + 'static,
430{
431    SkippedDevice {
432        device_type: candidate.device_type,
433        model: candidate.model.clone(),
434        path: candidate.path.clone(),
435        error: classify(error, Some(error.error_kind())),
436    }
437}
438
439async fn probe(device: &mut Device, network: Network) -> Result<(), HWIDeviceError> {
440    // XXX: Coldcard always needs unlocking
441    device.device().unlock(network).await?;
442    let info = device.info().await?;
443    if info.initialized != Some(false) {
444        device.fingerprint().await?;
445    }
446    Ok(())
447}
448
449/// Returns `None` for an empty scan or an error containing rejected candidates.
450pub fn no_device(skipped: Vec<SkippedDevice>) -> Result<Option<Device>, NoUsableDevice> {
451    if skipped.is_empty() {
452        return Ok(None);
453    }
454    Err(NoUsableDevice { skipped })
455}
456
457/// Returns whether an error chain contains a common user-cancellation error.
458pub fn is_user_cancelled(err: &(dyn std::error::Error + 'static)) -> bool {
459    classify_error(err).kind() == ErrorKind::UserCancelled
460}
461
462/// Classifies an error chain into a common error, keeping its kind, code, and message.
463pub fn classify_error(err: &(dyn std::error::Error + 'static)) -> common::Error {
464    classify(err, None)
465}
466
467fn classify(err: &(dyn std::error::Error + 'static), kind: Option<ErrorKind>) -> common::Error {
468    let message = err.to_string();
469    let mut kind = kind;
470    let mut source = Some(err);
471    while let Some(current) = source {
472        if let Some(error) = current.downcast_ref::<common::Error>() {
473            return reworded(error, message);
474        }
475        kind = kind.or_else(|| {
476            current
477                .downcast_ref::<HWIDeviceError>()
478                .and_then(HWIDeviceError::kind)
479        });
480        source = current.source();
481    }
482    common::Error::new(kind.unwrap_or(ErrorKind::Other), message)
483}
484
485fn reworded(error: &common::Error, message: String) -> common::Error {
486    if error.to_string() == message {
487        return error.clone();
488    }
489    let inner = error.to_string();
490    let message = if message.contains(&inner) {
491        message
492            .replace(&inner, error.message())
493            .trim_end_matches(": ")
494            .to_owned()
495    } else if message.contains(error.message()) {
496        message
497    } else {
498        format!("{message}: {}", error.message())
499    };
500    let mut outer = common::Error::new(error.kind(), message);
501    if let Some(code) = error.device_code() {
502        outer = outer.with_device_code(code);
503    }
504    if let Some(data) = error.data() {
505        outer = outer.with_data(data.to_vec());
506    }
507    outer
508}
509
510/// A candidate rejected during opening, selection, or probing.
511#[derive(Debug, Clone)]
512pub struct SkippedDevice {
513    /// The rejected device family.
514    pub device_type: DeviceType,
515    /// The discovery model identifier.
516    pub model: String,
517    /// The discovery path.
518    pub path: String,
519    /// The classified error.
520    pub error: common::Error,
521}
522
523/// A successfully opened device or a rejected candidate.
524pub enum ScanEntry {
525    /// An opened device.
526    Found(
527        /// The opened device.
528        Device,
529    ),
530    /// A rejected candidate.
531    Skipped(
532        /// The candidate and its classified error.
533        SkippedDevice,
534    ),
535}
536
537impl SkippedDevice {
538    /// Creates a rejected-candidate record by classifying an error.
539    pub fn new(
540        device_type: DeviceType,
541        model: impl Into<String>,
542        path: impl Into<String>,
543        error: &(dyn std::error::Error + 'static),
544    ) -> Self {
545        Self {
546            device_type,
547            model: model.into(),
548            path: path.into(),
549            error: classify_error(error),
550        }
551    }
552}
553
554impl ScanEntry {
555    /// Creates a skipped scan entry from candidate metadata and an error.
556    pub fn skipped(
557        device_type: DeviceType,
558        model: impl Into<String>,
559        path: impl Into<String>,
560        error: &(dyn std::error::Error + 'static),
561    ) -> Self {
562        Self::Skipped(SkippedDevice::new(device_type, model, path, error))
563    }
564}
565
566/// Opened devices and rejected candidates collected by a scan.
567#[derive(Default)]
568pub struct DeviceScan {
569    /// Successfully opened devices; metadata availability depends on the scan operation.
570    pub devices: Vec<Device>,
571    /// Candidates rejected during the scan.
572    pub skipped: Vec<SkippedDevice>,
573}
574
575impl Extend<ScanEntry> for DeviceScan {
576    fn extend<I: IntoIterator<Item = ScanEntry>>(&mut self, iter: I) {
577        for entry in iter {
578            match entry {
579                ScanEntry::Found(device) => self.devices.push(device),
580                ScanEntry::Skipped(skipped) => self.skipped.push(skipped),
581            }
582        }
583    }
584}
585
586/// A requested network absent from the device's nonempty reported network list.
587#[derive(Debug, Clone)]
588pub struct NetworkMismatch {
589    /// The requested network.
590    pub expected: Network,
591    /// The networks reported by the device.
592    pub reported: Vec<Network>,
593}
594
595/// Formats network names as a comma-separated string.
596pub fn networks_string(networks: &[Network]) -> String {
597    networks
598        .iter()
599        .map(|n| n.to_string())
600        .collect::<Vec<_>>()
601        .join(", ")
602}
603
604/// An opened wallet with discovery metadata and cached successful query results.
605///
606/// Cached information and fingerprints are not automatically invalidated when
607/// callers change the wallet or session through [`Self::device`].
608pub struct Device {
609    name: String,
610    device_type: DeviceType,
611    path: String,
612    model: String,
613    device: Box<dyn HWIDevice>,
614    is_emulated: bool,
615    fingerprint: Option<Fingerprint>,
616    info: Option<Info>,
617}
618
619impl Device {
620    /// Wraps an opened wallet with discovery metadata and empty query caches.
621    pub fn new(
622        name: &str,
623        device_type: DeviceType,
624        path: impl Into<String>,
625        model: impl Into<String>,
626        device: Box<dyn HWIDevice>,
627        is_emulated: bool,
628    ) -> Self {
629        Self {
630            name: name.into(),
631            device_type,
632            path: path.into(),
633            model: model.into(),
634            device,
635            is_emulated,
636            fingerprint: None,
637            info: None,
638        }
639    }
640
641    /// Returns the discovery display name.
642    pub fn name(&self) -> &str {
643        &self.name
644    }
645
646    /// Returns the device family.
647    pub fn device_type(&self) -> DeviceType {
648        self.device_type
649    }
650
651    /// Returns the source-specific discovery path.
652    pub fn path(&self) -> &str {
653        &self.path
654    }
655
656    /// Returns the discovery model identifier.
657    pub fn model(&self) -> &str {
658        &self.model
659    }
660
661    /// Returns the underlying wallet interface without invalidating query caches.
662    pub fn device(&mut self) -> &mut Box<dyn HWIDevice> {
663        &mut self.device
664    }
665
666    /// Returns whether discovery identified this device as an emulator.
667    pub fn is_emulated(&self) -> bool {
668        self.is_emulated
669    }
670
671    /// Returns the last successfully queried fingerprint, without performing I/O.
672    pub fn cached_fingerprint(&self) -> Option<Fingerprint> {
673        self.fingerprint
674    }
675
676    /// Returns cached information, without performing I/O.
677    pub fn cached_info(&self) -> Option<&Info> {
678        self.info.as_ref()
679    }
680
681    /// Returns the cached fingerprint or queries the wallet and caches success.
682    pub async fn fingerprint(&mut self) -> Result<Fingerprint, HWIDeviceError> {
683        if let Some(fingerprint) = self.fingerprint {
684            Ok(fingerprint)
685        } else {
686            let fingerprint = self.device.get_master_fingerprint().await?;
687            self.fingerprint = Some(fingerprint);
688            Ok(fingerprint)
689        }
690    }
691
692    /// Returns a mismatch when nonempty reported networks exclude `expected`.
693    ///
694    /// This may query information; an empty network list is treated as unknown.
695    pub async fn network_mismatch(
696        &mut self,
697        expected: Network,
698    ) -> Result<Option<NetworkMismatch>, HWIDeviceError> {
699        let info = self.info().await?;
700        if info.networks.is_empty() || info.networks.contains(&expected) {
701            return Ok(None);
702        }
703        Ok(Some(NetworkMismatch {
704            expected,
705            reported: info.networks,
706        }))
707    }
708
709    /// Returns cached information or queries the device and caches success.
710    ///
711    /// Specter-DIY instead receives synthetic default information with version
712    /// `"unavailable"`, because it has no version command.
713    pub async fn info(&mut self) -> Result<Info, HWIDeviceError> {
714        if let Some(ref info) = self.info {
715            Ok(info.clone())
716        } else if self.device_type == DeviceType::Specter {
717            // Specter-DIY has no version command, so asking would fail the call.
718            let info = Info {
719                version: "unavailable".into(),
720                ..Info::default()
721            };
722            self.info = Some(info.clone());
723            Ok(info)
724        } else {
725            let info = self.device.get_info().await?;
726            self.info = Some(info.clone());
727            Ok(info)
728        }
729    }
730}
731
732#[cfg(test)]
733mod tests {
734    use bhwi::bitcoin::{
735        bip32::{DerivationPath, Xpub},
736        psbt::Psbt,
737        secp256k1::ecdsa::Signature,
738    };
739    use futures::executor::block_on;
740
741    use super::*;
742    use crate::{
743        DeviceBackup, DeviceContext, DisplayAddress, RestoreOptions, SetupOptions,
744        WalletRegistration,
745    };
746
747    /// Answers only what `select` asks: unlock and the master fingerprint.
748    struct Wallet {
749        fingerprint: Fingerprint,
750        refuses_unlock: bool,
751    }
752
753    #[async_trait(?Send)]
754    impl HWIDevice for Wallet {
755        async fn backup_device(&mut self) -> Result<DeviceBackup, HWIDeviceError> {
756            unimplemented!()
757        }
758        async fn setup_device(
759            &mut self,
760            _: SetupOptions,
761            _: Option<DeviceContext>,
762        ) -> Result<bool, HWIDeviceError> {
763            unimplemented!()
764        }
765        async fn wipe_device(&mut self) -> Result<bool, HWIDeviceError> {
766            unimplemented!()
767        }
768        async fn restore_device(
769            &mut self,
770            _: RestoreOptions,
771            _: Option<DeviceContext>,
772        ) -> Result<bool, HWIDeviceError> {
773            unimplemented!()
774        }
775        async fn toggle_passphrase(&mut self) -> Result<bool, HWIDeviceError> {
776            unimplemented!()
777        }
778        async fn prompt_pin(&mut self) -> Result<bool, HWIDeviceError> {
779            unimplemented!()
780        }
781        async fn send_pin(&mut self, _: Option<DeviceContext>) -> Result<bool, HWIDeviceError> {
782            unimplemented!()
783        }
784        async fn unlock(&mut self, _: Network) -> Result<(), HWIDeviceError> {
785            if self.refuses_unlock {
786                let refusal: Box<dyn std::error::Error + Send + Sync> =
787                    "authentication refused".into();
788                return Err(refusal.into());
789            }
790            Ok(())
791        }
792        async fn get_info(&mut self) -> Result<Info, HWIDeviceError> {
793            unimplemented!()
794        }
795        async fn get_master_fingerprint(&mut self) -> Result<Fingerprint, HWIDeviceError> {
796            Ok(self.fingerprint)
797        }
798        async fn get_extended_pubkey(
799            &mut self,
800            _: DerivationPath,
801            _: bool,
802        ) -> Result<Xpub, HWIDeviceError> {
803            unimplemented!()
804        }
805        async fn sign_message(
806            &mut self,
807            _: &[u8],
808            _: DerivationPath,
809        ) -> Result<(u8, Signature), HWIDeviceError> {
810            unimplemented!()
811        }
812        async fn display_address(
813            &mut self,
814            _: DisplayAddress,
815            _: Option<DeviceContext>,
816        ) -> Result<String, HWIDeviceError> {
817            unimplemented!()
818        }
819        async fn register_wallet(
820            &mut self,
821            _: &str,
822            _: &str,
823        ) -> Result<WalletRegistration, HWIDeviceError> {
824            unimplemented!()
825        }
826        async fn sign_tx(
827            &mut self,
828            _: Psbt,
829            _: Option<DeviceContext>,
830        ) -> Result<Psbt, HWIDeviceError> {
831            unimplemented!()
832        }
833    }
834
835    const REFUSING: [u8; 4] = [0xaa; 4];
836    const UNLOCKED: [u8; 4] = [0xbb; 4];
837
838    #[derive(Debug, thiserror::Error)]
839    #[error("device went away")]
840    struct Unplugged;
841
842    impl ErrorKindOf for Unplugged {
843        fn error_kind(&self) -> ErrorKind {
844            ErrorKind::Disconnected
845        }
846    }
847
848    /// A wallet that refuses authentication, then one that unlocks.
849    struct Wallets(DeviceType);
850
851    #[async_trait(?Send)]
852    impl DeviceSource for Wallets {
853        type Error = Unplugged;
854
855        async fn list(&self, _: &DeviceSelector) -> Result<Vec<DeviceCandidate>, Self::Error> {
856            Ok(["refusing", "unlocked"]
857                .into_iter()
858                .map(|path| DeviceCandidate {
859                    device_type: self.0,
860                    name: "wallet".to_owned(),
861                    model: "wallet".to_owned(),
862                    path: path.to_owned(),
863                    is_emulated: true,
864                })
865                .collect())
866        }
867
868        async fn open(
869            &self,
870            candidate: &DeviceCandidate,
871            _: &DeviceSelector,
872            _: Option<&PairingCodePrompt>,
873            _: Option<&HostInteractionFactory>,
874        ) -> Result<Device, Self::Error> {
875            let refuses_unlock = candidate.path == "refusing";
876            let wallet = Wallet {
877                fingerprint: Fingerprint::from(if refuses_unlock { REFUSING } else { UNLOCKED }),
878                refuses_unlock,
879            };
880            Ok(Device::new(
881                &candidate.name,
882                candidate.device_type,
883                &candidate.path,
884                &candidate.model,
885                Box::new(wallet),
886                candidate.is_emulated,
887            ))
888        }
889    }
890
891    fn manager(fingerprint: Option<Fingerprint>) -> DeviceManager<Wallets> {
892        let selector = DeviceSelector {
893            fingerprint,
894            ..DeviceSelector::default()
895        };
896        DeviceManager::new(Wallets(DeviceType::Trezor), selector)
897    }
898
899    #[cfg(all(feature = "bitbox", any(feature = "trezor", feature = "keepkey")))]
900    #[test]
901    fn a_bitbox02_refuses_any_supplied_passphrase() {
902        for passphrase in [Some("secret"), Some(""), None] {
903            let selector = DeviceSelector {
904                fingerprint: Some(Fingerprint::from(UNLOCKED)),
905                passphrase: passphrase.map(|p| HostPassphrase::new(p.to_owned())),
906                ..DeviceSelector::default()
907            };
908            let result =
909                block_on(DeviceManager::new(Wallets(DeviceType::BitBox02), selector).select());
910            assert_eq!(
911                matches!(result, Err(SelectError::HostPassphraseRejected)),
912                passphrase.is_some()
913            );
914        }
915    }
916
917    #[test]
918    fn a_refused_unlock_ends_selection_when_no_fingerprint_was_named() {
919        let err = block_on(manager(None).select())
920            .err()
921            .expect("the refusal is reported");
922        assert!(matches!(err, SelectError::Device(_)));
923    }
924
925    #[test]
926    fn a_refused_unlock_is_skipped_while_looking_for_a_named_fingerprint() {
927        let unlocked = Fingerprint::from(UNLOCKED);
928        let (device, skipped) =
929            block_on(manager(Some(unlocked)).select()).expect("the named wallet is found");
930        assert_eq!(
931            device.expect("a wallet").cached_fingerprint(),
932            Some(unlocked)
933        );
934        assert_eq!(skipped.len(), 1);
935    }
936
937    #[test]
938    fn taproot_support_matches_python_hwi() {
939        assert!(can_sign_taproot(
940            DeviceType::Ledger,
941            "ledger_nano_s_simulator"
942        ));
943        assert!(!can_sign_taproot(
944            DeviceType::BitBox02,
945            "bitbox02_simulator"
946        ));
947        assert!(!can_sign_taproot(DeviceType::Jade, "jade_simulator"));
948        assert!(can_sign_taproot(
949            DeviceType::Coldcard,
950            "coldcard_simulator_edge"
951        ));
952        assert!(!can_sign_taproot(
953            DeviceType::Coldcard,
954            "coldcard_simulator"
955        ));
956        assert!(can_sign_taproot(DeviceType::Trezor, "trezor_t"));
957        assert!(!can_sign_taproot(DeviceType::Trezor, "trezor_one"));
958    }
959
960    #[test]
961    fn an_interpreter_error_reads_as_its_cause() {
962        let wrapped: crate::Error<std::io::Error, std::io::Error> = crate::Error::Interpreter(
963            bhwi::common::Error::new(ErrorKind::InvalidInput, "Passphrase too long"),
964        );
965        assert_eq!(wrapped.to_string(), "[InvalidInput] Passphrase too long");
966        assert_eq!(
967            std::error::Error::source(&wrapped)
968                .expect("the cause is kept")
969                .to_string(),
970            "[InvalidInput] Passphrase too long"
971        );
972
973        let skipped = SkippedDevice::new(DeviceType::KeepKey, "keepkey", "udp:11044", &wrapped);
974        assert_eq!(
975            skipped.error,
976            bhwi::common::Error::new(ErrorKind::InvalidInput, "Passphrase too long")
977        );
978        assert_eq!(
979            skipped.error.to_string(),
980            "[InvalidInput] Passphrase too long"
981        );
982    }
983
984    #[test]
985    fn a_transport_error_keeps_the_layer_that_carries_its_meaning() {
986        let wrapped = HWIDeviceError::with_kind(
987            crate::Error::<std::io::Error, std::io::Error>::Transport {
988                kind: ErrorKind::Transport,
989                error: std::io::Error::other("Broken pipe"),
990            },
991            ErrorKind::Transport,
992        );
993
994        let skipped = SkippedDevice::new(DeviceType::KeepKey, "keepkey", "udp:11044", &wrapped);
995        assert_eq!(skipped.error.kind(), ErrorKind::Transport);
996        assert_eq!(
997            skipped.error.to_string(),
998            "[Transport] transport error: Broken pipe"
999        );
1000    }
1001
1002    #[cfg(feature = "trezor")]
1003    struct Replies(std::collections::VecDeque<Result<Vec<u8>, std::io::Error>>);
1004
1005    #[cfg(feature = "trezor")]
1006    impl Replies {
1007        fn new(replies: impl IntoIterator<Item = Result<Vec<u8>, std::io::Error>>) -> Self {
1008            Self(replies.into_iter().collect())
1009        }
1010    }
1011
1012    #[cfg(feature = "trezor")]
1013    #[async_trait(?Send)]
1014    impl crate::Transport for Replies {
1015        type Error = std::io::Error;
1016
1017        async fn exchange(&mut self, _: &[u8], _: bool) -> Result<Vec<u8>, Self::Error> {
1018            self.0.pop_front().expect("a scripted reply")
1019        }
1020
1021        fn error_kind(&self, error: &Self::Error) -> ErrorKind {
1022            crate::transport::io_error_kind(error)
1023        }
1024    }
1025
1026    #[cfg(feature = "trezor")]
1027    fn raised<F>(error: &HWIDeviceError) -> common::Error
1028    where
1029        F: std::error::Error + 'static,
1030    {
1031        match std::error::Error::source(error)
1032            .and_then(|source| source.downcast_ref::<crate::Error<std::io::Error, F>>())
1033        {
1034            Some(crate::Error::Interpreter(error)) => error.clone(),
1035            other => panic!("expected an interpreter error, got {other:?}"),
1036        }
1037    }
1038
1039    #[cfg(feature = "trezor")]
1040    fn assert_kept_whole(error: &HWIDeviceError, raised: &common::Error) {
1041        assert_eq!(classify_error(error), *raised);
1042        let skipped = SkippedDevice::new(DeviceType::Trezor, "trezor_one", "udp:21324", error);
1043        assert_eq!(skipped.error, *raised);
1044        assert_eq!(skipped.error.to_string(), error.to_string());
1045    }
1046
1047    #[cfg(feature = "trezor")]
1048    fn trezor_reply(
1049        message_type: bhwi::trezor::api::MessageType,
1050        payload: &[u8],
1051    ) -> Result<Vec<u8>, std::io::Error> {
1052        Ok(bhwi::trezor::api::frame(message_type as u16, payload))
1053    }
1054
1055    #[cfg(feature = "trezor")]
1056    #[test]
1057    fn a_trezor_failure_keeps_its_kind_code_and_message() {
1058        use bhwi::trezor::{TrezorError, api::MessageType};
1059
1060        // `Failure`: field 1 is the code (7, PIN invalid), field 2 the message.
1061        let mut failure = vec![0x08, 0x07, 0x12, 0x0b];
1062        failure.extend_from_slice(b"PIN invalid");
1063        let mut trezor =
1064            crate::Trezor::new(Replies::new([trezor_reply(MessageType::Failure, &failure)]));
1065
1066        let error = block_on(HWIDevice::get_master_fingerprint(&mut trezor))
1067            .expect_err("the device reports a failure");
1068
1069        let raised = raised::<TrezorError>(&error);
1070        assert_eq!(raised.kind(), ErrorKind::WrongPin);
1071        assert_eq!(raised.device_code(), Some(common::DeviceCode::Trezor(7)));
1072        assert_eq!(raised.message(), "PIN invalid");
1073        assert_kept_whole(&error, &raised);
1074
1075        #[derive(Debug, thiserror::Error)]
1076        #[error("getting fingerprint failed")]
1077        struct Context(#[source] crate::HWIDeviceError);
1078        let classified = classify_error(&Context(error));
1079        assert_eq!(classified.kind(), ErrorKind::WrongPin);
1080        assert_eq!(
1081            classified.message(),
1082            "getting fingerprint failed: PIN invalid"
1083        );
1084
1085        #[derive(Debug, thiserror::Error)]
1086        #[error("getting fingerprint failed: {0}")]
1087        struct Quoting(#[source] common::Error);
1088        let quoted = classify_error(&Quoting(common::Error::new(
1089            ErrorKind::WrongPin,
1090            "PIN invalid",
1091        )));
1092        assert_eq!(
1093            quoted.to_string(),
1094            "[WrongPin] getting fingerprint failed: PIN invalid"
1095        );
1096    }
1097
1098    #[cfg(feature = "trezor")]
1099    #[test]
1100    fn a_disconnect_keeps_the_kind_recorded_by_the_device_error() {
1101        let mut trezor = crate::Trezor::new(Replies::new([Err(std::io::Error::new(
1102            std::io::ErrorKind::BrokenPipe,
1103            "device went away",
1104        ))]));
1105
1106        let error = block_on(HWIDevice::get_master_fingerprint(&mut trezor))
1107            .expect_err("the transport fails");
1108
1109        let classified = classify_error(&error);
1110        assert_eq!(classified.kind(), ErrorKind::Disconnected);
1111        assert_eq!(classified.device_code(), None);
1112        assert_eq!(classified.data(), None);
1113        assert_eq!(
1114            classified.to_string(),
1115            "[Disconnected] transport error: device went away"
1116        );
1117        let skipped = SkippedDevice::new(DeviceType::Trezor, "trezor_one", "udp:21324", &error);
1118        assert_eq!(skipped.error, classified);
1119        assert!(!is_user_cancelled(&error));
1120    }
1121
1122    #[cfg(feature = "trezor")]
1123    struct CancellingTrezor;
1124
1125    #[cfg(feature = "trezor")]
1126    #[async_trait(?Send)]
1127    impl DeviceSource for CancellingTrezor {
1128        type Error = Unplugged;
1129
1130        async fn list(&self, _: &DeviceSelector) -> Result<Vec<DeviceCandidate>, Self::Error> {
1131            Ok(vec![DeviceCandidate {
1132                device_type: DeviceType::Trezor,
1133                name: "trezor".to_owned(),
1134                model: "trezor_one".to_owned(),
1135                path: "udp:21324".to_owned(),
1136                is_emulated: true,
1137            }])
1138        }
1139
1140        async fn open(
1141            &self,
1142            candidate: &DeviceCandidate,
1143            _: &DeviceSelector,
1144            _: Option<&PairingCodePrompt>,
1145            _: Option<&HostInteractionFactory>,
1146        ) -> Result<Device, Self::Error> {
1147            use bhwi::trezor::api::MessageType;
1148            // `Failure`: field 1 is the code (4, action cancelled), field 2 the message.
1149            let mut failure = vec![0x08, 0x04, 0x12, 0x18];
1150            failure.extend_from_slice(b"Action cancelled by user");
1151            let replies = Replies::new([
1152                trezor_reply(MessageType::Features, &[]),
1153                trezor_reply(MessageType::Failure, &failure),
1154            ]);
1155            Ok(Device::new(
1156                &candidate.name,
1157                candidate.device_type,
1158                &candidate.path,
1159                &candidate.model,
1160                Box::new(crate::Trezor::new(replies)),
1161                candidate.is_emulated,
1162            ))
1163        }
1164    }
1165
1166    #[cfg(feature = "trezor")]
1167    #[test]
1168    fn a_cancel_on_the_device_ends_selection_instead_of_skipping_it() {
1169        let selector = DeviceSelector {
1170            fingerprint: Some(Fingerprint::from(UNLOCKED)),
1171            ..DeviceSelector::default()
1172        };
1173        let err = block_on(DeviceManager::new(CancellingTrezor, selector).select())
1174            .err()
1175            .expect("the cancel is reported");
1176
1177        let SelectError::Device(err) = err else {
1178            panic!("expected the device error, got {err:?}");
1179        };
1180        assert_eq!(err.kind(), Some(ErrorKind::UserCancelled));
1181        let raised = raised::<bhwi::trezor::TrezorError>(&err);
1182        assert_eq!(raised.device_code(), Some(common::DeviceCode::Trezor(4)));
1183
1184        let built = crate::HWIDeviceError::new(common::Error::new(
1185            ErrorKind::UserCancelled,
1186            "action canceled by the user",
1187        ));
1188        assert_eq!(built.kind(), Some(ErrorKind::UserCancelled));
1189        let boxed: Box<dyn std::error::Error + Send + Sync> = Box::new(common::Error::new(
1190            ErrorKind::UserCancelled,
1191            "action canceled by the user",
1192        ));
1193        let converted = crate::HWIDeviceError::from(boxed);
1194        assert_eq!(converted.kind(), Some(ErrorKind::UserCancelled));
1195    }
1196
1197    #[test]
1198    fn multisig_display_address_needs_a_descriptor_capable_device() {
1199        for device_type in DeviceType::ALL {
1200            assert_eq!(
1201                supports_multisig_display_address(device_type),
1202                matches!(
1203                    device_type,
1204                    DeviceType::Coldcard
1205                        | DeviceType::Jade
1206                        | DeviceType::KeepKey
1207                        | DeviceType::Trezor
1208                ),
1209                "{device_type}"
1210            );
1211        }
1212    }
1213}