safecoin_remote_wallet/
remote_wallet.rs

1#[cfg(feature = "hidapi")]
2use {crate::ledger::is_valid_ledger, parking_lot::Mutex};
3use {
4    crate::{
5        ledger::LedgerWallet,
6        ledger_error::LedgerError,
7        locator::{Locator, LocatorError, Manufacturer},
8    },
9    log::*,
10    parking_lot::RwLock,
11    solana_sdk::{
12        derivation_path::{DerivationPath, DerivationPathError},
13        pubkey::Pubkey,
14        signature::{Signature, SignerError},
15    },
16    std::{
17        sync::Arc,
18        time::{Duration, Instant},
19    },
20    thiserror::Error,
21};
22
23const HID_GLOBAL_USAGE_PAGE: u16 = 0xFF00;
24const HID_USB_DEVICE_CLASS: u8 = 0;
25
26/// Remote wallet error.
27#[derive(Error, Debug, Clone)]
28pub enum RemoteWalletError {
29    #[error("hidapi error")]
30    Hid(String),
31
32    #[error("device type mismatch")]
33    DeviceTypeMismatch,
34
35    #[error("device with non-supported product ID or vendor ID was detected")]
36    InvalidDevice,
37
38    #[error(transparent)]
39    DerivationPathError(#[from] DerivationPathError),
40
41    #[error("invalid input: {0}")]
42    InvalidInput(String),
43
44    #[error("invalid path: {0}")]
45    InvalidPath(String),
46
47    #[error(transparent)]
48    LedgerError(#[from] LedgerError),
49
50    #[error("no device found")]
51    NoDeviceFound,
52
53    #[error("protocol error: {0}")]
54    Protocol(&'static str),
55
56    #[error("pubkey not found for given address")]
57    PubkeyNotFound,
58
59    #[error("remote wallet operation rejected by the user")]
60    UserCancel,
61
62    #[error(transparent)]
63    LocatorError(#[from] LocatorError),
64}
65
66#[cfg(feature = "hidapi")]
67impl From<hidapi::HidError> for RemoteWalletError {
68    fn from(err: hidapi::HidError) -> RemoteWalletError {
69        RemoteWalletError::Hid(err.to_string())
70    }
71}
72
73impl From<RemoteWalletError> for SignerError {
74    fn from(err: RemoteWalletError) -> SignerError {
75        match err {
76            RemoteWalletError::Hid(hid_error) => SignerError::Connection(hid_error),
77            RemoteWalletError::DeviceTypeMismatch => SignerError::Connection(err.to_string()),
78            RemoteWalletError::InvalidDevice => SignerError::Connection(err.to_string()),
79            RemoteWalletError::InvalidInput(input) => SignerError::InvalidInput(input),
80            RemoteWalletError::LedgerError(e) => SignerError::Protocol(e.to_string()),
81            RemoteWalletError::NoDeviceFound => SignerError::NoDeviceFound,
82            RemoteWalletError::Protocol(e) => SignerError::Protocol(e.to_string()),
83            RemoteWalletError::UserCancel => {
84                SignerError::UserCancel("remote wallet operation rejected by the user".to_string())
85            }
86            _ => SignerError::Custom(err.to_string()),
87        }
88    }
89}
90
91/// Collection of connected RemoteWallets
92pub struct RemoteWalletManager {
93    #[cfg(feature = "hidapi")]
94    usb: Arc<Mutex<hidapi::HidApi>>,
95    devices: RwLock<Vec<Device>>,
96}
97
98impl RemoteWalletManager {
99    /// Create a new instance.
100    #[cfg(feature = "hidapi")]
101    pub fn new(usb: Arc<Mutex<hidapi::HidApi>>) -> Arc<Self> {
102        Arc::new(Self {
103            usb,
104            devices: RwLock::new(Vec::new()),
105        })
106    }
107
108    /// Repopulate device list
109    /// Note: this method iterates over and updates all devices
110    #[cfg(feature = "hidapi")]
111    pub fn update_devices(&self) -> Result<usize, RemoteWalletError> {
112        let mut usb = self.usb.lock();
113        usb.refresh_devices()?;
114        let devices = usb.device_list();
115        let num_prev_devices = self.devices.read().len();
116
117        let mut detected_devices = vec![];
118        let mut errors = vec![];
119        for device_info in devices.filter(|&device_info| {
120            is_valid_hid_device(device_info.usage_page(), device_info.interface_number())
121                && is_valid_ledger(device_info.vendor_id(), device_info.product_id())
122        }) {
123            match usb.open_path(device_info.path()) {
124                Ok(device) => {
125                    let mut ledger = LedgerWallet::new(device);
126                    let result = ledger.read_device(device_info);
127                    match result {
128                        Ok(info) => {
129                            ledger.pretty_path = info.get_pretty_path();
130                            let path = device_info.path().to_str().unwrap().to_string();
131                            trace!("Found device: {:?}", info);
132                            detected_devices.push(Device {
133                                path,
134                                info,
135                                wallet_type: RemoteWalletType::Ledger(Arc::new(ledger)),
136                            })
137                        }
138                        Err(err) => {
139                            error!("Error connecting to ledger device to read info: {}", err);
140                            errors.push(err)
141                        }
142                    }
143                }
144                Err(err) => error!("Error connecting to ledger device to read info: {}", err),
145            }
146        }
147
148        let num_curr_devices = detected_devices.len();
149        *self.devices.write() = detected_devices;
150
151        if num_curr_devices == 0 && !errors.is_empty() {
152            return Err(errors[0].clone());
153        }
154
155        Ok(num_curr_devices - num_prev_devices)
156    }
157
158    #[cfg(not(feature = "hidapi"))]
159    pub fn update_devices(&self) -> Result<usize, RemoteWalletError> {
160        Err(RemoteWalletError::Hid(
161            "hidapi crate compilation disabled in safecoin-remote-wallet.".to_string(),
162        ))
163    }
164
165    /// List connected and acknowledged wallets
166    pub fn list_devices(&self) -> Vec<RemoteWalletInfo> {
167        self.devices.read().iter().map(|d| d.info.clone()).collect()
168    }
169
170    /// Get a particular wallet
171    #[allow(unreachable_patterns)]
172    pub fn get_ledger(
173        &self,
174        host_device_path: &str,
175    ) -> Result<Arc<LedgerWallet>, RemoteWalletError> {
176        self.devices
177            .read()
178            .iter()
179            .find(|device| device.info.host_device_path == host_device_path)
180            .ok_or(RemoteWalletError::PubkeyNotFound)
181            .and_then(|device| match &device.wallet_type {
182                RemoteWalletType::Ledger(ledger) => Ok(ledger.clone()),
183                _ => Err(RemoteWalletError::DeviceTypeMismatch),
184            })
185    }
186
187    /// Get wallet info.
188    pub fn get_wallet_info(&self, pubkey: &Pubkey) -> Option<RemoteWalletInfo> {
189        self.devices
190            .read()
191            .iter()
192            .find(|d| &d.info.pubkey == pubkey)
193            .map(|d| d.info.clone())
194    }
195
196    /// Update devices in maximum `max_polling_duration` if it doesn't succeed
197    pub fn try_connect_polling(&self, max_polling_duration: &Duration) -> bool {
198        let start_time = Instant::now();
199        while start_time.elapsed() <= *max_polling_duration {
200            if let Ok(num_devices) = self.update_devices() {
201                let plural = if num_devices == 1 { "" } else { "s" };
202                trace!("{} Remote Wallet{} found", num_devices, plural);
203                return true;
204            }
205        }
206        false
207    }
208}
209
210/// `RemoteWallet` trait
211#[allow(unused_variables)]
212pub trait RemoteWallet<T> {
213    fn name(&self) -> &str {
214        "unimplemented"
215    }
216
217    /// Parse device info and get device base pubkey
218    fn read_device(&mut self, dev_info: &T) -> Result<RemoteWalletInfo, RemoteWalletError> {
219        unimplemented!();
220    }
221
222    /// Get safecoin pubkey from a RemoteWallet
223    fn get_pubkey(
224        &self,
225        derivation_path: &DerivationPath,
226        confirm_key: bool,
227    ) -> Result<Pubkey, RemoteWalletError> {
228        unimplemented!();
229    }
230
231    /// Sign transaction data with wallet managing pubkey at derivation path m/44'/19165'/<account>'/<change>'.
232    fn sign_message(
233        &self,
234        derivation_path: &DerivationPath,
235        data: &[u8],
236    ) -> Result<Signature, RemoteWalletError> {
237        unimplemented!();
238    }
239}
240
241/// `RemoteWallet` device
242#[derive(Debug)]
243pub struct Device {
244    #[allow(dead_code)]
245    pub(crate) path: String,
246    pub(crate) info: RemoteWalletInfo,
247    pub wallet_type: RemoteWalletType,
248}
249
250/// Remote wallet convenience enum to hold various wallet types
251#[derive(Debug)]
252pub enum RemoteWalletType {
253    Ledger(Arc<LedgerWallet>),
254}
255
256/// Remote wallet information.
257#[derive(Debug, Default, Clone)]
258pub struct RemoteWalletInfo {
259    /// RemoteWallet device model
260    pub model: String,
261    /// RemoteWallet device manufacturer
262    pub manufacturer: Manufacturer,
263    /// RemoteWallet device serial number
264    pub serial: String,
265    /// RemoteWallet host device path
266    pub host_device_path: String,
267    /// Base pubkey of device at Safecoin derivation path
268    pub pubkey: Pubkey,
269    /// Initial read error
270    pub error: Option<RemoteWalletError>,
271}
272
273impl RemoteWalletInfo {
274    pub fn parse_locator(locator: Locator) -> Self {
275        RemoteWalletInfo {
276            manufacturer: locator.manufacturer,
277            pubkey: locator.pubkey.unwrap_or_default(),
278            ..RemoteWalletInfo::default()
279        }
280    }
281
282    pub fn get_pretty_path(&self) -> String {
283        format!("usb://{}/{:?}", self.manufacturer, self.pubkey,)
284    }
285
286    pub(crate) fn matches(&self, other: &Self) -> bool {
287        self.manufacturer == other.manufacturer
288            && (self.pubkey == other.pubkey
289                || self.pubkey == Pubkey::default()
290                || other.pubkey == Pubkey::default())
291    }
292}
293
294/// Helper to determine if a device is a valid HID
295pub fn is_valid_hid_device(usage_page: u16, interface_number: i32) -> bool {
296    usage_page == HID_GLOBAL_USAGE_PAGE || interface_number == HID_USB_DEVICE_CLASS as i32
297}
298
299/// Helper to initialize hidapi and RemoteWalletManager
300#[cfg(feature = "hidapi")]
301pub fn initialize_wallet_manager() -> Result<Arc<RemoteWalletManager>, RemoteWalletError> {
302    let hidapi = Arc::new(Mutex::new(hidapi::HidApi::new()?));
303    Ok(RemoteWalletManager::new(hidapi))
304}
305#[cfg(not(feature = "hidapi"))]
306pub fn initialize_wallet_manager() -> Result<Arc<RemoteWalletManager>, RemoteWalletError> {
307    Err(RemoteWalletError::Hid(
308        "hidapi crate compilation disabled in safecoin-remote-wallet.".to_string(),
309    ))
310}
311
312pub fn maybe_wallet_manager() -> Result<Option<Arc<RemoteWalletManager>>, RemoteWalletError> {
313    let wallet_manager = initialize_wallet_manager()?;
314    let device_count = wallet_manager.update_devices()?;
315    if device_count > 0 {
316        Ok(Some(wallet_manager))
317    } else {
318        drop(wallet_manager);
319        Ok(None)
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn test_parse_locator() {
329        let pubkey = solana_sdk::pubkey::new_rand();
330        let locator = Locator {
331            manufacturer: Manufacturer::Ledger,
332            pubkey: Some(pubkey),
333        };
334        let wallet_info = RemoteWalletInfo::parse_locator(locator);
335        assert!(wallet_info.matches(&RemoteWalletInfo {
336            model: "nano-s".to_string(),
337            manufacturer: Manufacturer::Ledger,
338            serial: "".to_string(),
339            host_device_path: "/host/device/path".to_string(),
340            pubkey,
341            error: None,
342        }));
343
344        // Test that pubkey need not be populated
345        let locator = Locator {
346            manufacturer: Manufacturer::Ledger,
347            pubkey: None,
348        };
349        let wallet_info = RemoteWalletInfo::parse_locator(locator);
350        assert!(wallet_info.matches(&RemoteWalletInfo {
351            model: "nano-s".to_string(),
352            manufacturer: Manufacturer::Ledger,
353            serial: "".to_string(),
354            host_device_path: "/host/device/path".to_string(),
355            pubkey: Pubkey::default(),
356            error: None,
357        }));
358    }
359
360    #[test]
361    fn test_remote_wallet_info_matches() {
362        let pubkey = solana_sdk::pubkey::new_rand();
363        let info = RemoteWalletInfo {
364            manufacturer: Manufacturer::Ledger,
365            model: "Nano S".to_string(),
366            serial: "0001".to_string(),
367            host_device_path: "/host/device/path".to_string(),
368            pubkey,
369            error: None,
370        };
371        let mut test_info = RemoteWalletInfo {
372            manufacturer: Manufacturer::Unknown,
373            ..RemoteWalletInfo::default()
374        };
375        assert!(!info.matches(&test_info));
376        test_info.manufacturer = Manufacturer::Ledger;
377        assert!(info.matches(&test_info));
378        test_info.model = "Other".to_string();
379        assert!(info.matches(&test_info));
380        test_info.model = "Nano S".to_string();
381        assert!(info.matches(&test_info));
382        test_info.host_device_path = "/host/device/path".to_string();
383        assert!(info.matches(&test_info));
384        let another_pubkey = solana_sdk::pubkey::new_rand();
385        test_info.pubkey = another_pubkey;
386        assert!(!info.matches(&test_info));
387        test_info.pubkey = pubkey;
388        assert!(info.matches(&test_info));
389    }
390
391    #[test]
392    fn test_get_pretty_path() {
393        let pubkey = solana_sdk::pubkey::new_rand();
394        let pubkey_str = pubkey.to_string();
395        let remote_wallet_info = RemoteWalletInfo {
396            model: "nano-s".to_string(),
397            manufacturer: Manufacturer::Ledger,
398            serial: "".to_string(),
399            host_device_path: "/host/device/path".to_string(),
400            pubkey,
401            error: None,
402        };
403        assert_eq!(
404            remote_wallet_info.get_pretty_path(),
405            format!("usb://ledger/{}", pubkey_str)
406        );
407    }
408}