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_sdk::{
13        pubkey::Pubkey,
14        signature::{Signature, SignerError},
15    },
16    std::{
17        rc::Rc,
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>>) -> Rc<Self> {
102        Rc::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(Rc::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 solana-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<Rc<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 solana 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
232    /// `m/44'/501'/<account>'/<change>'`.
233    fn sign_message(
234        &self,
235        derivation_path: &DerivationPath,
236        data: &[u8],
237    ) -> Result<Signature, RemoteWalletError> {
238        unimplemented!();
239    }
240
241    /// Sign off-chain message with wallet managing pubkey at derivation path
242    /// `m/44'/501'/<account>'/<change>'`.
243    fn sign_offchain_message(
244        &self,
245        derivation_path: &DerivationPath,
246        message: &[u8],
247    ) -> Result<Signature, RemoteWalletError> {
248        unimplemented!();
249    }
250}
251
252/// `RemoteWallet` device
253#[derive(Debug)]
254pub struct Device {
255    pub(crate) path: String,
256    pub(crate) info: RemoteWalletInfo,
257    pub wallet_type: RemoteWalletType,
258}
259
260/// Remote wallet convenience enum to hold various wallet types
261#[derive(Debug)]
262pub enum RemoteWalletType {
263    Ledger(Rc<LedgerWallet>),
264}
265
266/// Remote wallet information.
267#[derive(Debug, Default, Clone)]
268pub struct RemoteWalletInfo {
269    /// RemoteWallet device model
270    pub model: String,
271    /// RemoteWallet device manufacturer
272    pub manufacturer: Manufacturer,
273    /// RemoteWallet device serial number
274    pub serial: String,
275    /// RemoteWallet host device path
276    pub host_device_path: String,
277    /// Base pubkey of device at Solana derivation path
278    pub pubkey: Pubkey,
279    /// Initial read error
280    pub error: Option<RemoteWalletError>,
281}
282
283impl RemoteWalletInfo {
284    pub fn parse_locator(locator: Locator) -> Self {
285        RemoteWalletInfo {
286            manufacturer: locator.manufacturer,
287            pubkey: locator.pubkey.unwrap_or_default(),
288            ..RemoteWalletInfo::default()
289        }
290    }
291
292    pub fn get_pretty_path(&self) -> String {
293        format!("usb://{}/{:?}", self.manufacturer, self.pubkey,)
294    }
295
296    pub(crate) fn matches(&self, other: &Self) -> bool {
297        self.manufacturer == other.manufacturer
298            && (self.pubkey == other.pubkey
299                || self.pubkey == Pubkey::default()
300                || other.pubkey == Pubkey::default())
301    }
302}
303
304/// Helper to determine if a device is a valid HID
305pub fn is_valid_hid_device(usage_page: u16, interface_number: i32) -> bool {
306    usage_page == HID_GLOBAL_USAGE_PAGE || interface_number == HID_USB_DEVICE_CLASS as i32
307}
308
309/// Helper to initialize hidapi and RemoteWalletManager
310#[cfg(feature = "hidapi")]
311pub fn initialize_wallet_manager() -> Result<Rc<RemoteWalletManager>, RemoteWalletError> {
312    let hidapi = Arc::new(Mutex::new(hidapi::HidApi::new()?));
313    Ok(RemoteWalletManager::new(hidapi))
314}
315#[cfg(not(feature = "hidapi"))]
316pub fn initialize_wallet_manager() -> Result<Rc<RemoteWalletManager>, RemoteWalletError> {
317    Err(RemoteWalletError::Hid(
318        "hidapi crate compilation disabled in solana-remote-wallet.".to_string(),
319    ))
320}
321
322pub fn maybe_wallet_manager() -> Result<Option<Rc<RemoteWalletManager>>, RemoteWalletError> {
323    let wallet_manager = initialize_wallet_manager()?;
324    let device_count = wallet_manager.update_devices()?;
325    if device_count > 0 {
326        Ok(Some(wallet_manager))
327    } else {
328        drop(wallet_manager);
329        Ok(None)
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn test_parse_locator() {
339        let pubkey = solana_sdk::pubkey::new_rand();
340        let locator = Locator {
341            manufacturer: Manufacturer::Ledger,
342            pubkey: Some(pubkey),
343        };
344        let wallet_info = RemoteWalletInfo::parse_locator(locator);
345        assert!(wallet_info.matches(&RemoteWalletInfo {
346            model: "nano-s".to_string(),
347            manufacturer: Manufacturer::Ledger,
348            serial: "".to_string(),
349            host_device_path: "/host/device/path".to_string(),
350            pubkey,
351            error: None,
352        }));
353
354        // Test that pubkey need not be populated
355        let locator = Locator {
356            manufacturer: Manufacturer::Ledger,
357            pubkey: None,
358        };
359        let wallet_info = RemoteWalletInfo::parse_locator(locator);
360        assert!(wallet_info.matches(&RemoteWalletInfo {
361            model: "nano-s".to_string(),
362            manufacturer: Manufacturer::Ledger,
363            serial: "".to_string(),
364            host_device_path: "/host/device/path".to_string(),
365            pubkey: Pubkey::default(),
366            error: None,
367        }));
368    }
369
370    #[test]
371    fn test_remote_wallet_info_matches() {
372        let pubkey = solana_sdk::pubkey::new_rand();
373        let info = RemoteWalletInfo {
374            manufacturer: Manufacturer::Ledger,
375            model: "Nano S".to_string(),
376            serial: "0001".to_string(),
377            host_device_path: "/host/device/path".to_string(),
378            pubkey,
379            error: None,
380        };
381        let mut test_info = RemoteWalletInfo {
382            manufacturer: Manufacturer::Unknown,
383            ..RemoteWalletInfo::default()
384        };
385        assert!(!info.matches(&test_info));
386        test_info.manufacturer = Manufacturer::Ledger;
387        assert!(info.matches(&test_info));
388        test_info.model = "Other".to_string();
389        assert!(info.matches(&test_info));
390        test_info.model = "Nano S".to_string();
391        assert!(info.matches(&test_info));
392        test_info.host_device_path = "/host/device/path".to_string();
393        assert!(info.matches(&test_info));
394        let another_pubkey = solana_sdk::pubkey::new_rand();
395        test_info.pubkey = another_pubkey;
396        assert!(!info.matches(&test_info));
397        test_info.pubkey = pubkey;
398        assert!(info.matches(&test_info));
399    }
400
401    #[test]
402    fn test_get_pretty_path() {
403        let pubkey = solana_sdk::pubkey::new_rand();
404        let pubkey_str = pubkey.to_string();
405        let remote_wallet_info = RemoteWalletInfo {
406            model: "nano-s".to_string(),
407            manufacturer: Manufacturer::Ledger,
408            serial: "".to_string(),
409            host_device_path: "/host/device/path".to_string(),
410            pubkey,
411            error: None,
412        };
413        assert_eq!(
414            remote_wallet_info.get_pretty_path(),
415            format!("usb://ledger/{pubkey_str}")
416        );
417    }
418}