diff --git a/Cargo.lock b/Cargo.lock index 8f9590d17cf498..3d257c2d2a0640 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4332,6 +4332,7 @@ dependencies = [ "semver 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", "solana-sdk 0.24.0", "thiserror 1.0.10 (registry+https://github.com/rust-lang/crates.io-index)", + "titlecase 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -5363,6 +5364,15 @@ dependencies = [ "crunchy 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "titlecase" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "lazy_static 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "regex 1.3.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "tokio" version = "0.1.22" @@ -6507,6 +6517,7 @@ dependencies = [ "checksum time 0.1.42 (registry+https://github.com/rust-lang/crates.io-index)" = "db8dcfca086c1143c9270ac42a2bbd8a7ee477b78ac8e45b19abfb0cbede4b6f" "checksum tiny-bip39 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)" = "1cd1fb03fe8e07d17cd851a624a9fff74642a997b67fbd1ccd77533241640d92" "checksum tiny-keccak 1.5.0 (registry+https://github.com/rust-lang/crates.io-index)" = "1d8a021c69bb74a44ccedb824a046447e2c84a01df9e5c20779750acb38e11b2" +"checksum titlecase 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "f565e410cfc24c2f2a89960b023ca192689d7f77d3f8d4f4af50c2d8affe1117" "checksum tokio 0.1.22 (registry+https://github.com/rust-lang/crates.io-index)" = "5a09c0b5bb588872ab2f09afa13ee6e9dac11e10a0ec9e8e3ba39a5a5d530af6" "checksum tokio 0.2.6 (registry+https://github.com/rust-lang/crates.io-index)" = "0e1bef565a52394086ecac0a6fa3b8ace4cb3a138ee1d96bd2b93283b56824e3" "checksum tokio-buf 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "8fb220f46c53859a4b7ec083e41dec9778ff0b1851c0942b211edb89e0ccdc46" diff --git a/remote-wallet/Cargo.toml b/remote-wallet/Cargo.toml index 4b84387144927b..0bd09f01da1333 100644 --- a/remote-wallet/Cargo.toml +++ b/remote-wallet/Cargo.toml @@ -10,10 +10,11 @@ homepage = "https://solana.com/" [dependencies] base32 = "0.4.0" +dialoguer = "0.5.0" hidapi = "1.1.1" -libusb = "0.3.0" log = "0.4.8" parking_lot = "0.7" +rusb = "0.5.5" semver = "0.9" solana-sdk = { path = "../sdk", version = "0.24.0" } thiserror = "1.0" diff --git a/remote-wallet/src/ledger.rs b/remote-wallet/src/ledger.rs index 44234c69d8e9a7..474b463f372bce 100644 --- a/remote-wallet/src/ledger.rs +++ b/remote-wallet/src/ledger.rs @@ -1,15 +1,19 @@ -use crate::remote_wallet::{DerivationPath, RemoteWallet, RemoteWalletError, RemoteWalletInfo}; +use crate::remote_wallet::{ + initialize_wallet_manager, DerivationPath, RemoteWallet, RemoteWalletError, RemoteWalletInfo, +}; +use dialoguer::{theme::ColorfulTheme, Select}; use log::*; use semver::Version as FirmwareVersion; use solana_sdk::{pubkey::Pubkey, signature::Signature, transaction::Transaction}; -use std::{cmp::min, fmt}; +use std::{cmp::min, fmt, sync::Arc}; const APDU_TAG: u8 = 0x05; const APDU_CLA: u8 = 0xe0; const APDU_PAYLOAD_HEADER_LEN: usize = 7; const SOL_DERIVATION_PATH_BE: [u8; 8] = [0x80, 0, 0, 44, 0x80, 0, 0x01, 0xF5]; // 44'/501', Solana - // const SOL_DERIVATION_PATH_BE: [u8; 8] = [0x80, 0, 0, 44, 0x80, 0, 0x00, 0x94]; // 44'/148', Stellar + +// const SOL_DERIVATION_PATH_BE: [u8; 8] = [0x80, 0, 0, 44, 0x80, 0, 0x00, 0x94]; // 44'/148', Stellar /// Ledger vendor ID const LEDGER_VID: u16 = 0x2c97; @@ -175,8 +179,9 @@ impl LedgerWallet { let status = (message[message.len() - 2] as usize) << 8 | (message[message.len() - 1] as usize); trace!("Read status {:x}", status); + #[allow(clippy::match_overlapping_arm)] match status { - // TODO: These need to be aligned with solana Ledger app error codes + // TODO: These need to be aligned with solana Ledger app error codes, and clippy allowance removed 0x6700 => Err(RemoteWalletError::Protocol("Incorrect length")), 0x6982 => Err(RemoteWalletError::Protocol( "Security status not satisfied (Canceled by user)", @@ -246,8 +251,9 @@ impl RemoteWallet for LedgerWallet { .manufacturer_string .clone() .unwrap_or_else(|| "Unknown".to_owned()) - .to_lowercase(); - let name = dev_info + .to_lowercase() + .replace(" ", "-"); + let model = dev_info .product_string .clone() .unwrap_or_else(|| "Unknown".to_owned()) @@ -259,7 +265,7 @@ impl RemoteWallet for LedgerWallet { .unwrap_or_else(|| "Unknown".to_owned()); self.get_pubkey(DerivationPath::default()) .map(|pubkey| RemoteWalletInfo { - name, + model, manufacturer, serial, pubkey, @@ -345,6 +351,35 @@ pub fn is_valid_ledger(vendor_id: u16, _product_id: u16) -> bool { vendor_id == LEDGER_VID } +/// +pub fn get_ledger_from_info( + info: RemoteWalletInfo, +) -> Result, RemoteWalletError> { + let wallet_manager = initialize_wallet_manager(); + let _device_count = wallet_manager.update_devices()?; + let devices = wallet_manager.list_devices(); + let (pubkeys, device_paths): (Vec, Vec) = devices + .iter() + .filter(|&device_info| device_info == &info) + .map(|device_info| (device_info.pubkey, device_info.get_pretty_path())) + .unzip(); + if pubkeys.is_empty() { + return Err(RemoteWalletError::NoDeviceFound); + } + let wallet_base_pubkey = if pubkeys.len() > 1 { + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Multiple hardware wallets found. Please select a device") + .default(0) + .items(&device_paths[..]) + .interact() + .unwrap(); + pubkeys[selection] + } else { + pubkeys[0] + }; + wallet_manager.get_ledger(&wallet_base_pubkey) +} + #[cfg(test)] mod tests { use super::*; @@ -364,7 +399,7 @@ mod tests { let ledger_base_pubkey = wallet_manager .list_devices() .iter() - .filter(|d| d.manufacturer == "Ledger".to_string()) + .filter(|d| d.manufacturer == "ledger".to_string()) .nth(0) .map(|d| d.pubkey.clone()) .expect("No ledger device detected"); diff --git a/remote-wallet/src/remote_wallet.rs b/remote-wallet/src/remote_wallet.rs index 879e3742f137fd..c4e0987a0347fa 100644 --- a/remote-wallet/src/remote_wallet.rs +++ b/remote-wallet/src/remote_wallet.rs @@ -4,6 +4,7 @@ use parking_lot::{Mutex, RwLock}; use solana_sdk::{pubkey::Pubkey, signature::Signature, transaction::Transaction}; use std::{ fmt, + str::FromStr, sync::Arc, time::{Duration, Instant}, }; @@ -24,11 +25,11 @@ pub enum RemoteWalletError { #[error("device with non-supported product ID or vendor ID was detected")] InvalidDevice, - #[error("no device arrived")] - NoDeviceArrived, + #[error("invalid path: {0}")] + InvalidPath(String), - #[error("no device left")] - NoDeviceLeft, + #[error("no device found")] + NoDeviceFound, #[error("protocol error: {0}")] Protocol(&'static str), @@ -171,10 +172,10 @@ pub enum RemoteWalletType { } /// Remote wallet information. -#[derive(Debug, Clone)] +#[derive(Debug, Default, Clone)] pub struct RemoteWalletInfo { - /// RemoteWallet device name - pub name: String, + /// RemoteWallet device model + pub model: String, /// RemoteWallet device manufacturer pub manufacturer: String, /// RemoteWallet device serial number @@ -183,7 +184,101 @@ pub struct RemoteWalletInfo { pub pubkey: Pubkey, } -#[derive(Default, PartialEq)] +impl RemoteWalletInfo { + pub fn parse_path(mut path: String) -> Result<(Self, DerivationPath), RemoteWalletError> { + if is_remote_wallet_path(&path).is_ok() { + let path = path.split_off(6); + let mut parts = path.split('/'); + let mut wallet_info = RemoteWalletInfo::default(); + let manufacturer = parts.next().unwrap(); + wallet_info.manufacturer = manufacturer.to_string(); + wallet_info.model = parts.next().unwrap_or("").to_string(); + wallet_info.pubkey = parts + .next() + .and_then(|pubkey_str| Pubkey::from_str(pubkey_str).ok()) + .unwrap_or_default(); + let derivation_path = parts + .next() + .map(|account| { + let account = account.parse::().unwrap(); + let change = parts.next().and_then(|change| change.parse::().ok()); + DerivationPath { account, change } + }) + .unwrap_or(DerivationPath { + account: 0, + change: None, + }); + Ok((wallet_info, derivation_path)) + } else { + Err(RemoteWalletError::InvalidPath(path)) + } + } + + pub fn get_pretty_path(&self) -> String { + format!( + "usb://{}/{}/{:?}", + self.manufacturer, self.model, self.pubkey, + ) + } +} + +impl PartialEq for RemoteWalletInfo { + fn eq(&self, other: &Self) -> bool { + self.manufacturer == other.manufacturer + && (self.model == other.model || self.model == "" || other.model == "") + && (self.pubkey == other.pubkey + || self.pubkey == Pubkey::default() + || other.pubkey == Pubkey::default()) + } +} + +pub fn is_remote_wallet_path(value: &str) -> Result<(), String> { + if value.starts_with("usb://") { + let (_, path) = value.split_at(6); + let mut parts = path.split('/'); + let manufacturer = parts.next().unwrap(); + if manufacturer != "" { + if let Some(_model) = parts.next() { + if let Some(pubkey_str) = parts.next() { + let _pubkey = Pubkey::from_str(pubkey_str).map_err(|e| { + format!( + "Unable to parse pubkey in remote wallet path, provided: {}, err: {:?}", + pubkey_str, e + ) + })?; + if let Some(account) = parts.next() { + let _account = account + .parse::() + .map_err(|e| { + format!( + "Unable to parse account in remote wallet path, provided: {}, err: {:?}", + account, e + ) + })?; + + if let Some(change) = parts.next() { + let _change = change + .parse::() + .map_err(|e| { + format!( + "Unable to parse change in remote wallet path, provided: {}, err: {:?}", + account, e + ) + })?; + } + } + } + } + return Ok(()); + } + } + Err(format!( + "Unable to parse input as remote wallet path, provided: {}", + value + )) +} + +#[derive(Default, PartialEq, Clone)] pub struct DerivationPath { pub account: u16, pub change: Option, @@ -210,3 +305,81 @@ pub fn initialize_wallet_manager() -> Arc { let hidapi = Arc::new(Mutex::new(hidapi::HidApi::new().unwrap())); RemoteWalletManager::new(hidapi) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_path() { + let pubkey = Pubkey::new_rand(); + assert_eq!( + RemoteWalletInfo::parse_path(format!("usb://ledger/nano-s/{:?}/1/2", pubkey)).unwrap(), + ( + RemoteWalletInfo { + model: "nano-s".to_string(), + manufacturer: "ledger".to_string(), + serial: "".to_string(), + pubkey, + }, + DerivationPath { + account: 1, + change: Some(2), + } + ) + ) + } + + #[test] + fn test_remote_wallet_info_partial_eq() { + let pubkey = Pubkey::new_rand(); + let info = RemoteWalletInfo { + manufacturer: "Ledger".to_string(), + model: "Nano S".to_string(), + serial: "0001".to_string(), + pubkey: pubkey.clone(), + }; + let mut test_info = RemoteWalletInfo::default(); + test_info.manufacturer = "Not Ledger".to_string(); + assert_ne!(info, test_info); + test_info.manufacturer = "Ledger".to_string(); + assert_eq!(info, test_info); + test_info.model = "Other".to_string(); + assert_ne!(info, test_info); + test_info.model = "Nano S".to_string(); + assert_eq!(info, test_info); + let another_pubkey = Pubkey::new_rand(); + test_info.pubkey = another_pubkey; + assert_ne!(info, test_info); + test_info.pubkey = pubkey; + assert_eq!(info, test_info); + } + + #[test] + fn test_is_remote_wallet_path() { + assert!(is_remote_wallet_path("usb://").is_err()); + assert_eq!(is_remote_wallet_path("usb://ledger"), Ok(())); + assert_eq!(is_remote_wallet_path("usb://ledger/nano-s"), Ok(())); + assert!(is_remote_wallet_path("usb://ledger/nano-s/notpubkey").is_err()); + + let pubkey = Pubkey::new_rand(); + assert_eq!( + is_remote_wallet_path(&format!("usb://ledger/nano-s/{:?}", pubkey)), + Ok(()) + ); + assert_eq!( + is_remote_wallet_path(&format!("usb://ledger/nano-s/{:?}/1", pubkey)), + Ok(()) + ); + assert!(is_remote_wallet_path(&format!("usb://ledger/nano-s/{:?}/a", pubkey)).is_err()); + assert!(is_remote_wallet_path(&format!("usb://ledger/nano-s/{:?}/65537", pubkey)).is_err()); + assert_eq!( + is_remote_wallet_path(&format!("usb://ledger/nano-s/{:?}/1/1", pubkey)), + Ok(()) + ); + assert!(is_remote_wallet_path(&format!("usb://ledger/nano-s/{:?}/1/b", pubkey)).is_err()); + assert!( + is_remote_wallet_path(&format!("usb://ledger/nano-s/{:?}/1/65537", pubkey)).is_err() + ); + } +}