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")]
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            is_valid_hid_device(device_info.usage_page(), device_info.interface_number())
120                && is_valid_ledger(device_info.vendor_id(), device_info.product_id())
121        }) {
122            match usb.open_path(device_info.path()) {
123                Ok(device) => {
124                    let mut ledger = LedgerWallet::new(device);
125                    let result = ledger.read_device(device_info);
126                    match result {
127                        Ok(info) => {
128                            ledger.pretty_path = info.get_pretty_path();
129                            let path = device_info.path().to_str().unwrap().to_string();
130                            trace!("Found device: {:?}", info);
131                            detected_devices.push(Device {
132                                path,
133                                info,
134                                wallet_type: RemoteWalletType::Ledger(Rc::new(ledger)),
135                            })
136                        }
137                        Err(err) => {
138                            error!("Error connecting to ledger device to read info: {}", err);
139                            errors.push(err)
140                        }
141                    }
142                }
143                Err(err) => error!("Error connecting to ledger device to read info: {}", err),
144            }
145        }
146
147        let num_curr_devices = detected_devices.len();
148        *self.devices.write() = detected_devices;
149
150        if num_curr_devices == 0 && !errors.is_empty() {
151            return Err(errors[0].clone());
152        }
153
154        Ok(num_curr_devices - num_prev_devices)
155    }
156
157    #[cfg(not(feature = "hidapi"))]
158    pub fn update_devices(&self) -> Result<usize, RemoteWalletError> {
159        Err(RemoteWalletError::Hid(
160            "hidapi crate compilation disabled in solana-remote-wallet.".to_string(),
161        ))
162    }
163
164    /// List connected and acknowledged wallets
165    pub fn list_devices(&self) -> Vec<RemoteWalletInfo> {
166        self.devices.read().iter().map(|d| d.info.clone()).collect()
167    }
168
169    /// Get a particular wallet
170    #[allow(unreachable_patterns)]
171    pub fn get_ledger(
172        &self,
173        host_device_path: &str,
174    ) -> Result<Rc<LedgerWallet>, RemoteWalletError> {
175        self.devices
176            .read()
177            .iter()
178            .find(|device| device.info.host_device_path == host_device_path)
179            .ok_or(RemoteWalletError::PubkeyNotFound)
180            .and_then(|device| match &device.wallet_type {
181                RemoteWalletType::Ledger(ledger) => Ok(ledger.clone()),
182                _ => Err(RemoteWalletError::DeviceTypeMismatch),
183            })
184    }
185
186    /// Get wallet info.
187    pub fn get_wallet_info(&self, pubkey: &Pubkey) -> Option<RemoteWalletInfo> {
188        self.devices
189            .read()
190            .iter()
191            .find(|d| &d.info.pubkey == pubkey)
192            .map(|d| d.info.clone())
193    }
194
195    /// Update devices in maximum `max_polling_duration` if it doesn't succeed
196    pub fn try_connect_polling(&self, max_polling_duration: &Duration) -> bool {
197        let start_time = Instant::now();
198        while start_time.elapsed() <= *max_polling_duration {
199            if let Ok(num_devices) = self.update_devices() {
200                let plural = if num_devices == 1 { "" } else { "s" };
201                trace!("{} Remote Wallet{} found", num_devices, plural);
202                return true;
203            }
204        }
205        false
206    }
207}
208
209/// `RemoteWallet` trait
210#[allow(unused_variables)]
211pub trait RemoteWallet<T> {
212    fn name(&self) -> &str {
213        "unimplemented"
214    }
215
216    /// Parse device info and get device base pubkey
217    fn read_device(&mut self, dev_info: &T) -> Result<RemoteWalletInfo, RemoteWalletError> {
218        unimplemented!();
219    }
220
221    /// Get solana pubkey from a RemoteWallet
222    fn get_pubkey(
223        &self,
224        derivation_path: &DerivationPath,
225        confirm_key: bool,
226    ) -> Result<Pubkey, RemoteWalletError> {
227        unimplemented!();
228    }
229
230    /// Sign transaction data with wallet managing pubkey at derivation path
231    /// `m/44'/501'/<account>'/<change>'`.
232    fn sign_message(
233        &self,
234        derivation_path: &DerivationPath,
235        data: &[u8],
236    ) -> Result<Signature, RemoteWalletError> {
237        unimplemented!();
238    }
239
240    /// Sign off-chain message with wallet managing pubkey at derivation path
241    /// `m/44'/501'/<account>'/<change>'`.
242    fn sign_offchain_message(
243        &self,
244        derivation_path: &DerivationPath,
245        message: &[u8],
246    ) -> Result<Signature, RemoteWalletError> {
247        unimplemented!();
248    }
249}
250
251/// `RemoteWallet` device
252#[derive(Debug)]
253pub struct Device {
254    pub(crate) path: String,
255    pub(crate) info: RemoteWalletInfo,
256    pub wallet_type: RemoteWalletType,
257}
258
259/// Remote wallet convenience enum to hold various wallet types
260#[derive(Debug)]
261pub enum RemoteWalletType {
262    Ledger(Rc<LedgerWallet>),
263}
264
265/// Remote wallet information.
266#[derive(Debug, Default, Clone)]
267pub struct RemoteWalletInfo {
268    /// RemoteWallet device model
269    pub model: String,
270    /// RemoteWallet device manufacturer
271    pub manufacturer: Manufacturer,
272    /// RemoteWallet device serial number
273    pub serial: String,
274    /// RemoteWallet host device path
275    pub host_device_path: String,
276    /// Base pubkey of device at Solana derivation path
277    pub pubkey: Pubkey,
278    /// Initial read error
279    pub error: Option<RemoteWalletError>,
280}
281
282impl RemoteWalletInfo {
283    pub fn parse_locator(locator: Locator) -> Self {
284        RemoteWalletInfo {
285            manufacturer: locator.manufacturer,
286            pubkey: locator.pubkey.unwrap_or_default(),
287            ..RemoteWalletInfo::default()
288        }
289    }
290
291    pub fn get_pretty_path(&self) -> String {
292        format!("usb://{}/{:?}", self.manufacturer, self.pubkey,)
293    }
294
295    pub(crate) fn matches(&self, other: &Self) -> bool {
296        self.manufacturer == other.manufacturer
297            && (self.pubkey == other.pubkey
298                || self.pubkey == Pubkey::default()
299                || other.pubkey == Pubkey::default())
300    }
301}
302
303/// Helper to determine if a device is a valid HID
304pub fn is_valid_hid_device(usage_page: u16, interface_number: i32) -> bool {
305    usage_page == HID_GLOBAL_USAGE_PAGE || interface_number == HID_USB_DEVICE_CLASS as i32
306}
307
308/// Helper to initialize hidapi and RemoteWalletManager
309#[cfg(feature = "hidapi")]
310pub fn initialize_wallet_manager() -> Result<Rc<RemoteWalletManager>, RemoteWalletError> {
311    let hidapi = Arc::new(Mutex::new(hidapi::HidApi::new()?));
312    Ok(RemoteWalletManager::new(hidapi))
313}
314#[cfg(not(feature = "hidapi"))]
315pub fn initialize_wallet_manager() -> Result<Rc<RemoteWalletManager>, RemoteWalletError> {
316    Err(RemoteWalletError::Hid(
317        "hidapi crate compilation disabled in solana-remote-wallet.".to_string(),
318    ))
319}
320
321pub fn maybe_wallet_manager() -> Result<Option<Rc<RemoteWalletManager>>, RemoteWalletError> {
322    let wallet_manager = initialize_wallet_manager()?;
323    let device_count = wallet_manager.update_devices()?;
324    if device_count > 0 {
325        Ok(Some(wallet_manager))
326    } else {
327        drop(wallet_manager);
328        Ok(None)
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn test_parse_locator() {
338        let pubkey = solana_pubkey::new_rand();
339        let locator = Locator {
340            manufacturer: Manufacturer::Ledger,
341            pubkey: Some(pubkey),
342        };
343        let wallet_info = RemoteWalletInfo::parse_locator(locator);
344        assert!(wallet_info.matches(&RemoteWalletInfo {
345            model: "nano-s".to_string(),
346            manufacturer: Manufacturer::Ledger,
347            serial: "".to_string(),
348            host_device_path: "/host/device/path".to_string(),
349            pubkey,
350            error: None,
351        }));
352
353        // Test that pubkey need not be populated
354        let locator = Locator {
355            manufacturer: Manufacturer::Ledger,
356            pubkey: None,
357        };
358        let wallet_info = RemoteWalletInfo::parse_locator(locator);
359        assert!(wallet_info.matches(&RemoteWalletInfo {
360            model: "nano-s".to_string(),
361            manufacturer: Manufacturer::Ledger,
362            serial: "".to_string(),
363            host_device_path: "/host/device/path".to_string(),
364            pubkey: Pubkey::default(),
365            error: None,
366        }));
367    }
368
369    #[test]
370    fn test_remote_wallet_info_matches() {
371        let pubkey = solana_pubkey::new_rand();
372        let info = RemoteWalletInfo {
373            manufacturer: Manufacturer::Ledger,
374            model: "Nano S".to_string(),
375            serial: "0001".to_string(),
376            host_device_path: "/host/device/path".to_string(),
377            pubkey,
378            error: None,
379        };
380        let mut test_info = RemoteWalletInfo {
381            manufacturer: Manufacturer::Unknown,
382            ..RemoteWalletInfo::default()
383        };
384        assert!(!info.matches(&test_info));
385        test_info.manufacturer = Manufacturer::Ledger;
386        assert!(info.matches(&test_info));
387        test_info.model = "Other".to_string();
388        assert!(info.matches(&test_info));
389        test_info.model = "Nano S".to_string();
390        assert!(info.matches(&test_info));
391        test_info.host_device_path = "/host/device/path".to_string();
392        assert!(info.matches(&test_info));
393        let another_pubkey = solana_pubkey::new_rand();
394        test_info.pubkey = another_pubkey;
395        assert!(!info.matches(&test_info));
396        test_info.pubkey = pubkey;
397        assert!(info.matches(&test_info));
398    }
399
400    #[test]
401    fn test_get_pretty_path() {
402        let pubkey = solana_pubkey::new_rand();
403        let pubkey_str = pubkey.to_string();
404        let remote_wallet_info = RemoteWalletInfo {
405            model: "nano-s".to_string(),
406            manufacturer: Manufacturer::Ledger,
407            serial: "".to_string(),
408            host_device_path: "/host/device/path".to_string(),
409            pubkey,
410            error: None,
411        };
412        assert_eq!(
413            remote_wallet_info.get_pretty_path(),
414            format!("usb://ledger/{pubkey_str}")
415        );
416    }
417}