solana_remote_wallet/
remote_wallet.rs

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