Ledger hardware wallet integration (#8068)
* Initial remote wallet module * Add clap derivation tooling * Add remote-wallet path apis * Implement remote-wallet in solana-keygen * Implement remote-wallet in cli for read-only pubkey usage * Linux: Use udev backend; add udev rules tool * Ignore Ledger live test * Cli api adjustments
This commit is contained in:
45
Cargo.lock
generated
45
Cargo.lock
generated
@ -144,6 +144,11 @@ dependencies = [
|
||||
"libc 0.2.66 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base32"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.10.1"
|
||||
@ -789,6 +794,16 @@ dependencies = [
|
||||
"syn 0.15.42 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dialoguer"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"console 0.9.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"lazy_static 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"tempfile 3.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "diff"
|
||||
version = "0.1.11"
|
||||
@ -1406,6 +1421,16 @@ name = "hex_fmt"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "hidapi"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"cc 1.0.49 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"libc 0.2.66 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"pkg-config 0.3.17 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "histogram"
|
||||
version = "0.6.9"
|
||||
@ -3672,6 +3697,7 @@ dependencies = [
|
||||
"clap 2.33.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"rpassword 4.0.5 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"semver 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"solana-remote-wallet 0.24.0",
|
||||
"solana-sdk 0.24.0",
|
||||
"tiny-bip39 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"url 2.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
@ -3707,6 +3733,7 @@ dependencies = [
|
||||
"solana-faucet 0.24.0",
|
||||
"solana-logger 0.24.0",
|
||||
"solana-net-utils 0.24.0",
|
||||
"solana-remote-wallet 0.24.0",
|
||||
"solana-runtime 0.24.0",
|
||||
"solana-sdk 0.24.0",
|
||||
"solana-stake-program 0.24.0",
|
||||
@ -3985,6 +4012,7 @@ dependencies = [
|
||||
"num_cpus 1.12.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"solana-clap-utils 0.24.0",
|
||||
"solana-cli-config 0.24.0",
|
||||
"solana-remote-wallet 0.24.0",
|
||||
"solana-sdk 0.24.0",
|
||||
"tiny-bip39 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
@ -4266,6 +4294,20 @@ dependencies = [
|
||||
"smallvec 0.6.10 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solana-remote-wallet"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"base32 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"dialoguer 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"hidapi 1.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"log 0.4.8 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"parking_lot 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"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)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solana-runtime"
|
||||
version = "0.24.0"
|
||||
@ -5954,6 +5996,7 @@ dependencies = [
|
||||
"checksum autocfg 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "f8aac770f1885fd7e387acedd76065302551364496e46b3dd00860b2f8359b9d"
|
||||
"checksum backtrace 0.3.40 (registry+https://github.com/rust-lang/crates.io-index)" = "924c76597f0d9ca25d762c25a4d369d51267536465dc5064bdf0eb073ed477ea"
|
||||
"checksum backtrace-sys 0.1.32 (registry+https://github.com/rust-lang/crates.io-index)" = "5d6575f128516de27e3ce99689419835fce9643a9b215a14d2b5b685be018491"
|
||||
"checksum base32 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa"
|
||||
"checksum base64 0.10.1 (registry+https://github.com/rust-lang/crates.io-index)" = "0b25d992356d2eb0ed82172f5248873db5560c4721f564b13cb5193bda5e668e"
|
||||
"checksum base64 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)" = "b41b7ea54a0c9d92199de89e20e58d49f02f8e699814ef3fdf266f6f748d15c7"
|
||||
"checksum bech32 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)" = "58946044516aa9dc922182e0d6e9d124a31aafe6b421614654eb27cf90cec09c"
|
||||
@ -6028,6 +6071,7 @@ dependencies = [
|
||||
"checksum curve25519-dalek 1.2.3 (registry+https://github.com/rust-lang/crates.io-index)" = "8b7dcd30ba50cdf88b55b033456138b7c0ac4afdc436d82e1b79f370f24cc66d"
|
||||
"checksum data-encoding 2.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "f4f47ca1860a761136924ddd2422ba77b2ea54fe8cc75b9040804a0d9d32ad97"
|
||||
"checksum derivative 1.0.3 (registry+https://github.com/rust-lang/crates.io-index)" = "942ca430eef7a3806595a6737bc388bf51adb888d3fc0dd1b50f1c170167ee3a"
|
||||
"checksum dialoguer 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)" = "94616e25d2c04fc97253d145f6ca33ad84a584258dc70c4e621cc79a57f903b6"
|
||||
"checksum diff 0.1.11 (registry+https://github.com/rust-lang/crates.io-index)" = "3c2b69f912779fbb121ceb775d74d51e915af17aaebc38d28a592843a2dd0a3a"
|
||||
"checksum difference 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "524cbf6897b527295dff137cec09ecf3a05f4fddffd7dfcd1585403449e74198"
|
||||
"checksum digest 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)" = "03b072242a8cbaf9c145665af9d250c59af3b958f83ed6824e13533cf76d5b90"
|
||||
@ -6101,6 +6145,7 @@ dependencies = [
|
||||
"checksum hex-literal 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "961de220ec9a91af2e1e5bd80d02109155695e516771762381ef8581317066e0"
|
||||
"checksum hex-literal-impl 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "06095d08c7c05760f11a071b3e1d4c5b723761c01bd8d7201c30a9536668a612"
|
||||
"checksum hex_fmt 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "b07f60793ff0a4d9cef0f18e63b5357e06209987153a64648c972c1e5aff336f"
|
||||
"checksum hidapi 1.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "289d0fb85e9ea18785708d4d735eca4f480b533b005e6f8aa2ffba0207800c75"
|
||||
"checksum histogram 0.6.9 (registry+https://github.com/rust-lang/crates.io-index)" = "12cb882ccb290b8646e554b157ab0b71e64e8d5bef775cd66b6531e52d302669"
|
||||
"checksum hmac 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)" = "5dcb5e64cda4c23119ab41ba960d1e170a774c8e4b9d9e6a9bc18aabf5e59695"
|
||||
"checksum http 0.1.21 (registry+https://github.com/rust-lang/crates.io-index)" = "d6ccf5ede3a895d8856620237b2f02972c1bbc78d2965ad7fe8838d4a0ed41f0"
|
||||
|
@ -43,6 +43,7 @@ members = [
|
||||
"archiver",
|
||||
"archiver-lib",
|
||||
"archiver-utils",
|
||||
"remote-wallet",
|
||||
"runtime",
|
||||
"sdk",
|
||||
"sdk-c",
|
||||
|
@ -12,6 +12,7 @@ edition = "2018"
|
||||
clap = "2.33.0"
|
||||
rpassword = "4.0"
|
||||
semver = "0.9.0"
|
||||
solana-remote-wallet = { path = "../remote-wallet", version = "0.24.0" }
|
||||
solana-sdk = { path = "../sdk", version = "0.24.0" }
|
||||
tiny-bip39 = "0.7.0"
|
||||
url = "2.1.0"
|
||||
|
@ -1,6 +1,7 @@
|
||||
use crate::keypair::{keypair_from_seed_phrase, ASK_KEYWORD, SKIP_SEED_PHRASE_VALIDATION_ARG};
|
||||
use chrono::DateTime;
|
||||
use clap::ArgMatches;
|
||||
use solana_remote_wallet::remote_wallet::DerivationPath;
|
||||
use solana_sdk::{
|
||||
clock::UnixTimestamp,
|
||||
native_token::sol_to_lamports,
|
||||
@ -100,6 +101,16 @@ pub fn amount_of(matches: &ArgMatches<'_>, name: &str, unit: &str) -> Option<u64
|
||||
}
|
||||
}
|
||||
|
||||
pub fn derivation_of(matches: &ArgMatches<'_>, name: &str) -> Option<DerivationPath> {
|
||||
matches.value_of(name).map(|derivation_str| {
|
||||
let derivation_str = derivation_str.replace("'", "");
|
||||
let mut parts = derivation_str.split('/');
|
||||
let account = parts.next().unwrap().parse::<u16>().unwrap();
|
||||
let change = parts.next().map(|change| change.parse::<u16>().unwrap());
|
||||
DerivationPath { account, change }
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@ -277,4 +288,40 @@ mod tests {
|
||||
.get_matches_from(vec!["test", "--single", "1.5", "--unit", "lamports"]);
|
||||
assert_eq!(amount_of(&matches, "single", "unit"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derivation_of() {
|
||||
let matches = app()
|
||||
.clone()
|
||||
.get_matches_from(vec!["test", "--single", "2/3"]);
|
||||
assert_eq!(
|
||||
derivation_of(&matches, "single"),
|
||||
Some(DerivationPath {
|
||||
account: 2,
|
||||
change: Some(3)
|
||||
})
|
||||
);
|
||||
assert_eq!(derivation_of(&matches, "another"), None);
|
||||
let matches = app()
|
||||
.clone()
|
||||
.get_matches_from(vec!["test", "--single", "2"]);
|
||||
assert_eq!(
|
||||
derivation_of(&matches, "single"),
|
||||
Some(DerivationPath {
|
||||
account: 2,
|
||||
change: None
|
||||
})
|
||||
);
|
||||
assert_eq!(derivation_of(&matches, "another"), None);
|
||||
let matches = app()
|
||||
.clone()
|
||||
.get_matches_from(vec!["test", "--single", "2'/3'"]);
|
||||
assert_eq!(
|
||||
derivation_of(&matches, "single"),
|
||||
Some(DerivationPath {
|
||||
account: 2,
|
||||
change: Some(3)
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,10 @@
|
||||
use crate::keypair::ASK_KEYWORD;
|
||||
use chrono::DateTime;
|
||||
use solana_sdk::hash::Hash;
|
||||
use solana_sdk::pubkey::Pubkey;
|
||||
use solana_sdk::signature::{read_keypair_file, Signature};
|
||||
use solana_sdk::{
|
||||
hash::Hash,
|
||||
pubkey::Pubkey,
|
||||
signature::{read_keypair_file, Signature},
|
||||
};
|
||||
use std::str::FromStr;
|
||||
|
||||
// Return an error if a pubkey cannot be parsed.
|
||||
@ -141,3 +143,47 @@ pub fn is_rfc3339_datetime(value: String) -> Result<(), String> {
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
pub fn is_derivation(value: String) -> Result<(), String> {
|
||||
let value = value.replace("'", "");
|
||||
let mut parts = value.split('/');
|
||||
let account = parts.next().unwrap();
|
||||
account
|
||||
.parse::<u16>()
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Unable to parse derivation, provided: {}, err: {:?}",
|
||||
account, e
|
||||
)
|
||||
})
|
||||
.and_then(|_| {
|
||||
if let Some(change) = parts.next() {
|
||||
change.parse::<u16>().map_err(|e| {
|
||||
format!(
|
||||
"Unable to parse derivation, provided: {}, err: {:?}",
|
||||
change, e
|
||||
)
|
||||
})
|
||||
} else {
|
||||
Ok(0)
|
||||
}
|
||||
})
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_derivation() {
|
||||
assert_eq!(is_derivation("2".to_string()), Ok(()));
|
||||
assert_eq!(is_derivation("0".to_string()), Ok(()));
|
||||
assert_eq!(is_derivation("0/2".to_string()), Ok(()));
|
||||
assert_eq!(is_derivation("0'/2'".to_string()), Ok(()));
|
||||
assert!(is_derivation("a".to_string()).is_err());
|
||||
assert!(is_derivation("65537".to_string()).is_err());
|
||||
assert!(is_derivation("a/b".to_string()).is_err());
|
||||
assert!(is_derivation("0/65537".to_string()).is_err());
|
||||
}
|
||||
}
|
||||
|
@ -32,8 +32,8 @@ pub const SKIP_SEED_PHRASE_VALIDATION_ARG: ArgConstant<'static> = ArgConstant {
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Source {
|
||||
File,
|
||||
Generated,
|
||||
Path,
|
||||
SeedPhrase,
|
||||
}
|
||||
|
||||
@ -131,7 +131,12 @@ pub fn keypair_input(
|
||||
keypair_from_seed_phrase(keypair_name, skip_validation, true)
|
||||
.map(|keypair| KeypairWithSource::new(keypair, Source::SeedPhrase))
|
||||
} else if let Some(keypair_file) = matches.value_of(keypair_match_name) {
|
||||
read_keypair_file(keypair_file).map(|keypair| KeypairWithSource::new(keypair, Source::File))
|
||||
if keypair_file.starts_with("usb://") {
|
||||
Ok(KeypairWithSource::new(Keypair::new(), Source::Path))
|
||||
} else {
|
||||
read_keypair_file(keypair_file)
|
||||
.map(|keypair| KeypairWithSource::new(keypair, Source::Path))
|
||||
}
|
||||
} else {
|
||||
Ok(KeypairWithSource::new(Keypair::new(), Source::Generated))
|
||||
}
|
||||
|
@ -34,6 +34,7 @@ solana-config-program = { path = "../programs/config", version = "0.24.0" }
|
||||
solana-faucet = { path = "../faucet", version = "0.24.0" }
|
||||
solana-logger = { path = "../logger", version = "0.24.0" }
|
||||
solana-net-utils = { path = "../net-utils", version = "0.24.0" }
|
||||
solana-remote-wallet = { path = "../remote-wallet", version = "0.24.0" }
|
||||
solana-runtime = { path = "../runtime", version = "0.24.0" }
|
||||
solana-sdk = { path = "../sdk", version = "0.24.0" }
|
||||
solana-stake-program = { path = "../programs/stake", version = "0.24.0" }
|
||||
|
@ -20,6 +20,10 @@ use solana_client::{client_error::ClientError, rpc_client::RpcClient};
|
||||
use solana_faucet::faucet::request_airdrop_transaction;
|
||||
#[cfg(test)]
|
||||
use solana_faucet::faucet_mock::request_airdrop_transaction;
|
||||
use solana_remote_wallet::{
|
||||
ledger::get_ledger_from_info,
|
||||
remote_wallet::{DerivationPath, RemoteWallet, RemoteWalletInfo},
|
||||
};
|
||||
use solana_sdk::{
|
||||
bpf_loader,
|
||||
clock::{Epoch, Slot},
|
||||
@ -439,6 +443,7 @@ pub struct CliConfig {
|
||||
pub json_rpc_url: String,
|
||||
pub keypair: Keypair,
|
||||
pub keypair_path: Option<String>,
|
||||
pub derivation_path: Option<DerivationPath>,
|
||||
pub rpc_client: Option<RpcClient>,
|
||||
pub verbose: bool,
|
||||
}
|
||||
@ -453,6 +458,22 @@ impl CliConfig {
|
||||
pub fn default_json_rpc_url() -> String {
|
||||
"http://127.0.0.1:8899".to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn pubkey(&self) -> Result<Pubkey, Box<dyn std::error::Error>> {
|
||||
if let Some(path) = &self.keypair_path {
|
||||
if path.starts_with("usb://") {
|
||||
let (remote_wallet_info, mut derivation_path) =
|
||||
RemoteWalletInfo::parse_path(path.to_string())?;
|
||||
if let Some(derivation) = &self.derivation_path {
|
||||
let derivation = derivation.clone();
|
||||
derivation_path = derivation;
|
||||
}
|
||||
let ledger = get_ledger_from_info(remote_wallet_info)?;
|
||||
return Ok(ledger.get_pubkey(derivation_path)?);
|
||||
}
|
||||
}
|
||||
Ok(self.keypair.pubkey())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CliConfig {
|
||||
@ -465,6 +486,7 @@ impl Default for CliConfig {
|
||||
json_rpc_url: Self::default_json_rpc_url(),
|
||||
keypair: Keypair::new(),
|
||||
keypair_path: Some(Self::default_keypair_path()),
|
||||
derivation_path: None,
|
||||
rpc_client: None,
|
||||
verbose: false,
|
||||
}
|
||||
@ -845,7 +867,7 @@ fn process_create_address_with_seed(
|
||||
seed: &str,
|
||||
program_id: &Pubkey,
|
||||
) -> ProcessResult {
|
||||
let config_pubkey = config.keypair.pubkey();
|
||||
let config_pubkey = config.pubkey()?;
|
||||
let from_pubkey = from_pubkey.unwrap_or(&config_pubkey);
|
||||
let address = create_address_with_seed(from_pubkey, seed, program_id)?;
|
||||
Ok(address.to_string())
|
||||
@ -858,12 +880,13 @@ fn process_airdrop(
|
||||
lamports: u64,
|
||||
use_lamports_unit: bool,
|
||||
) -> ProcessResult {
|
||||
let pubkey = config.pubkey()?;
|
||||
println!(
|
||||
"Requesting airdrop of {} from {}",
|
||||
build_balance_message(lamports, use_lamports_unit, true),
|
||||
faucet_addr
|
||||
);
|
||||
let previous_balance = match rpc_client.retry_get_balance(&config.keypair.pubkey(), 5)? {
|
||||
let previous_balance = match rpc_client.retry_get_balance(&pubkey, 5)? {
|
||||
Some(lamports) => lamports,
|
||||
None => {
|
||||
return Err(CliError::RpcRequestError(
|
||||
@ -873,10 +896,10 @@ fn process_airdrop(
|
||||
}
|
||||
};
|
||||
|
||||
request_and_confirm_airdrop(&rpc_client, faucet_addr, &config.keypair.pubkey(), lamports)?;
|
||||
request_and_confirm_airdrop(&rpc_client, faucet_addr, &pubkey, lamports)?;
|
||||
|
||||
let current_balance = rpc_client
|
||||
.retry_get_balance(&config.keypair.pubkey(), 5)?
|
||||
.retry_get_balance(&pubkey, 5)?
|
||||
.unwrap_or(previous_balance);
|
||||
|
||||
Ok(build_balance_message(
|
||||
@ -892,7 +915,7 @@ fn process_balance(
|
||||
pubkey: &Option<Pubkey>,
|
||||
use_lamports_unit: bool,
|
||||
) -> ProcessResult {
|
||||
let pubkey = pubkey.unwrap_or(config.keypair.pubkey());
|
||||
let pubkey = pubkey.unwrap_or(config.pubkey()?);
|
||||
let balance = rpc_client.retry_get_balance(&pubkey, 5)?;
|
||||
match balance {
|
||||
Some(lamports) => Ok(build_balance_message(lamports, use_lamports_unit, true)),
|
||||
@ -1260,6 +1283,9 @@ pub fn process_command(config: &CliConfig) -> ProcessResult {
|
||||
println_name_value("RPC URL:", &config.json_rpc_url);
|
||||
if let Some(keypair_path) = &config.keypair_path {
|
||||
println_name_value("Keypair Path:", keypair_path);
|
||||
if keypair_path.starts_with("usb://") {
|
||||
println_name_value("Pubkey:", &format!("{:?}", config.pubkey()?));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1275,7 +1301,7 @@ pub fn process_command(config: &CliConfig) -> ProcessResult {
|
||||
match &config.command {
|
||||
// Cluster Query Commands
|
||||
// Get address of this client
|
||||
CliCommand::Address => Ok(format!("{}", config.keypair.pubkey())),
|
||||
CliCommand::Address => Ok(format!("{}", config.pubkey()?)),
|
||||
|
||||
// Return software version of solana-cli and cluster entrypoint node
|
||||
CliCommand::Catchup { node_pubkey } => process_catchup(&rpc_client, node_pubkey),
|
||||
|
@ -2,7 +2,8 @@ use clap::{crate_description, crate_name, AppSettings, Arg, ArgGroup, ArgMatches
|
||||
use console::style;
|
||||
|
||||
use solana_clap_utils::{
|
||||
input_validators::is_url,
|
||||
input_parsers::derivation_of,
|
||||
input_validators::{is_derivation, is_url},
|
||||
keypair::{
|
||||
self, keypair_input, KeypairWithSource, ASK_SEED_PHRASE_ARG,
|
||||
SKIP_SEED_PHRASE_VALIDATION_ARG,
|
||||
@ -25,7 +26,7 @@ fn parse_settings(matches: &ArgMatches<'_>) -> Result<bool, Box<dyn error::Error
|
||||
let config = Config::load(config_file).unwrap_or_default();
|
||||
if let Some(field) = subcommand_matches.value_of("specific_setting") {
|
||||
let (field_name, value, default_value) = match field {
|
||||
"url" => ("RPC Url", config.url, CliConfig::default_json_rpc_url()),
|
||||
"url" => ("RPC URL", config.url, CliConfig::default_json_rpc_url()),
|
||||
"keypair" => (
|
||||
"Key Path",
|
||||
config.keypair_path,
|
||||
@ -37,12 +38,12 @@ fn parse_settings(matches: &ArgMatches<'_>) -> Result<bool, Box<dyn error::Error
|
||||
} else {
|
||||
println_name_value("Config File:", config_file);
|
||||
println_name_value_or(
|
||||
"RPC Url:",
|
||||
"RPC URL:",
|
||||
&config.url,
|
||||
&CliConfig::default_json_rpc_url(),
|
||||
);
|
||||
println_name_value_or(
|
||||
"Key Path:",
|
||||
"Keypair Path:",
|
||||
&config.keypair_path,
|
||||
&CliConfig::default_keypair_path(),
|
||||
);
|
||||
@ -106,7 +107,7 @@ pub fn parse_args(matches: &ArgMatches<'_>) -> Result<CliConfig, Box<dyn error::
|
||||
let (keypair, keypair_path) = if require_keypair {
|
||||
let KeypairWithSource { keypair, source } = keypair_input(&matches, "keypair")?;
|
||||
match source {
|
||||
keypair::Source::File => (
|
||||
keypair::Source::Path => (
|
||||
keypair,
|
||||
Some(matches.value_of("keypair").unwrap().to_string()),
|
||||
),
|
||||
@ -126,12 +127,16 @@ pub fn parse_args(matches: &ArgMatches<'_>) -> Result<CliConfig, Box<dyn error::
|
||||
default_keypair_path
|
||||
};
|
||||
|
||||
let keypair = read_keypair_file(&keypair_path).or_else(|err| {
|
||||
let keypair = if keypair_path.starts_with("usb://") {
|
||||
keypair
|
||||
} else {
|
||||
read_keypair_file(&keypair_path).or_else(|err| {
|
||||
Err(CliError::BadParameter(format!(
|
||||
"{}: Unable to open keypair file: {}",
|
||||
err, keypair_path
|
||||
)))
|
||||
})?;
|
||||
})?
|
||||
};
|
||||
|
||||
(keypair, Some(keypair_path))
|
||||
}
|
||||
@ -146,6 +151,7 @@ pub fn parse_args(matches: &ArgMatches<'_>) -> Result<CliConfig, Box<dyn error::
|
||||
json_rpc_url,
|
||||
keypair,
|
||||
keypair_path,
|
||||
derivation_path: derivation_of(matches, "derivation_path"),
|
||||
rpc_client: None,
|
||||
verbose: matches.is_present("verbose"),
|
||||
})
|
||||
@ -189,7 +195,15 @@ fn main() -> Result<(), Box<dyn error::Error>> {
|
||||
.value_name("PATH")
|
||||
.global(true)
|
||||
.takes_value(true)
|
||||
.help("/path/to/id.json"),
|
||||
.help("/path/to/id.json or usb://remote/wallet/path"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("derivation_path")
|
||||
.long("derivation-path")
|
||||
.value_name("ACCOUNT or ACCOUNT/CHANGE")
|
||||
.takes_value(true)
|
||||
.validator(is_derivation)
|
||||
.help("Derivation path to use: m/44'/501'/ACCOUNT'/CHANGE'; default key is device base pubkey: m/44'/501'/0'")
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("verbose")
|
||||
|
@ -15,6 +15,7 @@ dirs = "2.0.2"
|
||||
num_cpus = "1.12.0"
|
||||
solana-clap-utils = { path = "../clap-utils", version = "0.24.0" }
|
||||
solana-cli-config = { path = "../cli-config", version = "0.24.0" }
|
||||
solana-remote-wallet = { path = "../remote-wallet", version = "0.24.0" }
|
||||
solana-sdk = { path = "../sdk", version = "0.24.0" }
|
||||
tiny-bip39 = "0.7.0"
|
||||
|
||||
|
@ -5,12 +5,20 @@ use clap::{
|
||||
SubCommand,
|
||||
};
|
||||
use num_cpus;
|
||||
use solana_clap_utils::keypair::{
|
||||
use solana_clap_utils::{
|
||||
input_parsers::derivation_of,
|
||||
input_validators::is_derivation,
|
||||
keypair::{
|
||||
keypair_from_seed_phrase, prompt_passphrase, ASK_KEYWORD, SKIP_SEED_PHRASE_VALIDATION_ARG,
|
||||
},
|
||||
};
|
||||
use solana_cli_config::config::{Config, CONFIG_FILE};
|
||||
use solana_remote_wallet::{
|
||||
ledger::get_ledger_from_info,
|
||||
remote_wallet::{RemoteWallet, RemoteWalletInfo},
|
||||
};
|
||||
use solana_sdk::{
|
||||
pubkey::write_pubkey_file,
|
||||
pubkey::{write_pubkey_file, Pubkey},
|
||||
signature::{
|
||||
keypair_from_seed, read_keypair, read_keypair_file, write_keypair, write_keypair_file,
|
||||
Keypair, KeypairUtil, Signature,
|
||||
@ -65,11 +73,47 @@ fn get_keypair_from_matches(
|
||||
} else if keypair == ASK_KEYWORD {
|
||||
let skip_validation = matches.is_present(SKIP_SEED_PHRASE_VALIDATION_ARG.name);
|
||||
keypair_from_seed_phrase("pubkey recovery", skip_validation, false)
|
||||
} else if keypair.starts_with("usb://") {
|
||||
Err(String::from("Remote wallet signing not yet implemented").into())
|
||||
} else {
|
||||
read_keypair_file(keypair)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_pubkey_from_matches(
|
||||
matches: &ArgMatches,
|
||||
config: Config,
|
||||
) -> Result<Pubkey, Box<dyn error::Error>> {
|
||||
let mut path = dirs::home_dir().expect("home directory");
|
||||
let keypair = if matches.is_present("keypair") {
|
||||
matches.value_of("keypair").unwrap()
|
||||
} else if config.keypair_path != "" {
|
||||
&config.keypair_path
|
||||
} else {
|
||||
path.extend(&[".config", "solana", "id.json"]);
|
||||
path.to_str().unwrap()
|
||||
};
|
||||
|
||||
if keypair == "-" {
|
||||
let mut stdin = std::io::stdin();
|
||||
read_keypair(&mut stdin).map(|keypair| keypair.pubkey())
|
||||
} else if keypair == ASK_KEYWORD {
|
||||
let skip_validation = matches.is_present(SKIP_SEED_PHRASE_VALIDATION_ARG.name);
|
||||
keypair_from_seed_phrase("pubkey recovery", skip_validation, false)
|
||||
.map(|keypair| keypair.pubkey())
|
||||
} else if keypair.starts_with("usb://") {
|
||||
let (remote_wallet_info, mut derivation_path) =
|
||||
RemoteWalletInfo::parse_path(keypair.to_string())?;
|
||||
if let Some(derivation) = derivation_of(matches, "derivation_path") {
|
||||
derivation_path = derivation;
|
||||
}
|
||||
let ledger = get_ledger_from_info(remote_wallet_info)?;
|
||||
Ok(ledger.get_pubkey(derivation_path)?)
|
||||
} else {
|
||||
read_keypair_file(keypair).map(|keypair| keypair.pubkey())
|
||||
}
|
||||
}
|
||||
|
||||
fn output_keypair(
|
||||
keypair: &Keypair,
|
||||
outfile: &str,
|
||||
@ -326,7 +370,7 @@ fn main() -> Result<(), Box<dyn error::Error>> {
|
||||
.index(1)
|
||||
.value_name("PATH")
|
||||
.takes_value(true)
|
||||
.help("Path to keypair file"),
|
||||
.help("Path to keypair file or remote wallet"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name(SKIP_SEED_PHRASE_VALIDATION_ARG.name)
|
||||
@ -346,6 +390,14 @@ fn main() -> Result<(), Box<dyn error::Error>> {
|
||||
.short("f")
|
||||
.long("force")
|
||||
.help("Overwrite the output file if it exists"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("derivation_path")
|
||||
.long("derivation-path")
|
||||
.value_name("ACCOUNT or ACCOUNT/CHANGE")
|
||||
.takes_value(true)
|
||||
.validator(is_derivation)
|
||||
.help("Derivation path to use: m/44'/501'/ACCOUNT'/CHANGE'; default key is device base pubkey: m/44'/501'/0'")
|
||||
),
|
||||
)
|
||||
.subcommand(
|
||||
@ -382,14 +434,14 @@ fn main() -> Result<(), Box<dyn error::Error>> {
|
||||
|
||||
match matches.subcommand() {
|
||||
("pubkey", Some(matches)) => {
|
||||
let keypair = get_keypair_from_matches(matches, config)?;
|
||||
let pubkey = get_pubkey_from_matches(matches, config)?;
|
||||
|
||||
if matches.is_present("outfile") {
|
||||
let outfile = matches.value_of("outfile").unwrap();
|
||||
check_for_overwrite(&outfile, &matches);
|
||||
write_pubkey_file(outfile, keypair.pubkey())?;
|
||||
write_pubkey_file(outfile, pubkey)?;
|
||||
} else {
|
||||
println!("{}", keypair.pubkey());
|
||||
println!("{}", pubkey);
|
||||
}
|
||||
}
|
||||
("new", Some(matches)) => {
|
||||
|
30
remote-wallet/Cargo.toml
Normal file
30
remote-wallet/Cargo.toml
Normal file
@ -0,0 +1,30 @@
|
||||
[package]
|
||||
authors = ["Solana Maintainers <maintainers@solana.com>"]
|
||||
edition = "2018"
|
||||
name = "solana-remote-wallet"
|
||||
description = "Blockchain, Rebuilt for Scale"
|
||||
version = "0.24.0"
|
||||
repository = "https://github.com/solana-labs/solana"
|
||||
license = "Apache-2.0"
|
||||
homepage = "https://solana.com/"
|
||||
|
||||
[dependencies]
|
||||
base32 = "0.4.0"
|
||||
dialoguer = "0.5.0"
|
||||
hidapi = { version = "1.1.1", default-features = false }
|
||||
log = "0.4.8"
|
||||
parking_lot = "0.7"
|
||||
semver = "0.9"
|
||||
solana-sdk = { path = "../sdk", version = "0.24.0" }
|
||||
thiserror = "1.0"
|
||||
|
||||
[features]
|
||||
default = ["linux-static-hidraw"]
|
||||
linux-static-libusb = ["hidapi/linux-static-libusb"]
|
||||
linux-static-hidraw = ["hidapi/linux-static-hidraw"]
|
||||
linux-shared-libusb = ["hidapi/linux-shared-libusb"]
|
||||
linux-shared-hidraw = ["hidapi/linux-shared-hidraw"]
|
||||
|
||||
[[bin]]
|
||||
name = "solana-ledger-udev"
|
||||
path = "src/bin/ledger-udev.rs"
|
51
remote-wallet/src/bin/ledger-udev.rs
Normal file
51
remote-wallet/src/bin/ledger-udev.rs
Normal file
@ -0,0 +1,51 @@
|
||||
/// Implements udev rules on Linux for supported Ledger devices
|
||||
/// This script must be run with sudo privileges
|
||||
use std::{
|
||||
error,
|
||||
fs::{File, OpenOptions},
|
||||
io::{Read, Write},
|
||||
path::Path,
|
||||
process::Command,
|
||||
};
|
||||
|
||||
const LEDGER_UDEV_RULES: &str = r#"# Nano S
|
||||
SUBSYSTEMS=="usb", ATTRS{idVendor}=="2c97", ATTRS{idProduct}=="0001|1000|1001|1002|1003|1004|1005|1006|1007|1008|1009|100a|100b|100c|100d|100e|100f|1010|1011|1012|1013|1014|1015|1016|1017|1018|1019|101a|101b|101c|101d|101e|101f", TAG+="uaccess", TAG+="udev-acl", MODE="0666"
|
||||
# Nano X
|
||||
SUBSYSTEMS=="usb", ATTRS{idVendor}=="2c97", ATTRS{idProduct}=="0004|4000|4001|4002|4003|4004|4005|4006|4007|4008|4009|400a|400b|400c|400d|400e|400f|4010|4011|4012|4013|4014|4015|4016|4017|4018|4019|401a|401b|401c|401d|401e|401f", TAG+="uaccess", TAG+="udev-acl", MODE="0666""#;
|
||||
|
||||
const LEDGER_UDEV_RULES_LOCATION: &str = "/etc/udev/rules.d/20-hw1.rules";
|
||||
|
||||
fn main() -> Result<(), Box<dyn error::Error>> {
|
||||
if cfg!(target_os = "linux") {
|
||||
let mut contents = String::new();
|
||||
if Path::new("/etc/udev/rules.d/20-hw1.rules").exists() {
|
||||
let mut file = File::open(LEDGER_UDEV_RULES_LOCATION)?;
|
||||
file.read_to_string(&mut contents)?;
|
||||
}
|
||||
if !contents.contains(LEDGER_UDEV_RULES) {
|
||||
let mut file = OpenOptions::new()
|
||||
.append(true)
|
||||
.create(true)
|
||||
.open(LEDGER_UDEV_RULES_LOCATION)
|
||||
.map_err(|e| {
|
||||
println!("Could not write to file; this script requires sudo privileges");
|
||||
e
|
||||
})?;
|
||||
file.write_all(LEDGER_UDEV_RULES.as_bytes())?;
|
||||
|
||||
Command::new("udevadm").arg("trigger").output().unwrap();
|
||||
|
||||
Command::new("udevadm")
|
||||
.args(&["control", "--reload-rules"])
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
println!("Ledger udev rules written");
|
||||
} else {
|
||||
println!("Ledger udev rules already in place");
|
||||
}
|
||||
} else {
|
||||
println!("Mismatched target_os; udev rules only required on linux os");
|
||||
}
|
||||
Ok(())
|
||||
}
|
442
remote-wallet/src/ledger.rs
Normal file
442
remote-wallet/src/ledger.rs
Normal file
@ -0,0 +1,442 @@
|
||||
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, 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
|
||||
|
||||
/// Ledger vendor ID
|
||||
const LEDGER_VID: u16 = 0x2c97;
|
||||
/// Ledger product IDs: Nano S and Nano X
|
||||
const LEDGER_NANO_S_PIDS: [u16; 33] = [
|
||||
0x0001, 0x1000, 0x1001, 0x1002, 0x1003, 0x1004, 0x1005, 0x1006, 0x1007, 0x1008, 0x1009, 0x100a,
|
||||
0x100b, 0x100c, 0x100d, 0x100e, 0x100f, 0x1010, 0x1011, 0x1012, 0x1013, 0x1014, 0x1015, 0x1016,
|
||||
0x1017, 0x1018, 0x1019, 0x101a, 0x101b, 0x101c, 0x101d, 0x101e, 0x101f,
|
||||
];
|
||||
const LEDGER_NANO_X_PIDS: [u16; 33] = [
|
||||
0x0004, 0x4000, 0x4001, 0x4002, 0x4003, 0x4004, 0x4005, 0x4006, 0x4007, 0x4008, 0x4009, 0x400a,
|
||||
0x400b, 0x400c, 0x400d, 0x400e, 0x400f, 0x4010, 0x4011, 0x4012, 0x4013, 0x4014, 0x4015, 0x4016,
|
||||
0x4017, 0x4018, 0x4019, 0x401a, 0x401b, 0x401c, 0x401d, 0x401e, 0x401f,
|
||||
];
|
||||
const LEDGER_TRANSPORT_HEADER_LEN: usize = 5;
|
||||
|
||||
const MAX_CHUNK_SIZE: usize = 255;
|
||||
|
||||
const HID_PACKET_SIZE: usize = 64 + HID_PREFIX_ZERO;
|
||||
|
||||
#[cfg(windows)]
|
||||
const HID_PREFIX_ZERO: usize = 1;
|
||||
#[cfg(not(windows))]
|
||||
const HID_PREFIX_ZERO: usize = 0;
|
||||
|
||||
mod commands {
|
||||
pub const GET_APP_CONFIGURATION: u8 = 0x06;
|
||||
pub const GET_SOL_PUBKEY: u8 = 0x02;
|
||||
pub const SIGN_SOL_TRANSACTION: u8 = 0x04;
|
||||
}
|
||||
|
||||
/// Ledger Wallet device
|
||||
pub struct LedgerWallet {
|
||||
pub device: hidapi::HidDevice,
|
||||
}
|
||||
|
||||
impl fmt::Debug for LedgerWallet {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "HidDevice")
|
||||
}
|
||||
}
|
||||
|
||||
impl LedgerWallet {
|
||||
pub fn new(device: hidapi::HidDevice) -> Self {
|
||||
Self { device }
|
||||
}
|
||||
|
||||
// Transport Protocol:
|
||||
// * Communication Channel Id (2 bytes big endian )
|
||||
// * Command Tag (1 byte)
|
||||
// * Packet Sequence ID (2 bytes big endian)
|
||||
// * Payload (Optional)
|
||||
//
|
||||
// Payload
|
||||
// * APDU Total Length (2 bytes big endian)
|
||||
// * APDU_CLA (1 byte)
|
||||
// * APDU_INS (1 byte)
|
||||
// * APDU_P1 (1 byte)
|
||||
// * APDU_P2 (1 byte)
|
||||
// * APDU_LENGTH (1 byte)
|
||||
// * APDU_Payload (Variable)
|
||||
//
|
||||
fn write(&self, command: u8, p1: u8, p2: u8, data: &[u8]) -> Result<(), RemoteWalletError> {
|
||||
let data_len = data.len();
|
||||
let mut offset = 0;
|
||||
let mut sequence_number = 0;
|
||||
let mut hid_chunk = [0_u8; HID_PACKET_SIZE];
|
||||
|
||||
while sequence_number == 0 || offset < data_len {
|
||||
let header = if sequence_number == 0 {
|
||||
LEDGER_TRANSPORT_HEADER_LEN + APDU_PAYLOAD_HEADER_LEN
|
||||
} else {
|
||||
LEDGER_TRANSPORT_HEADER_LEN
|
||||
};
|
||||
let size = min(64 - header, data_len - offset);
|
||||
{
|
||||
let chunk = &mut hid_chunk[HID_PREFIX_ZERO..];
|
||||
chunk[0..5].copy_from_slice(&[
|
||||
0x01,
|
||||
0x01,
|
||||
APDU_TAG,
|
||||
(sequence_number >> 8) as u8,
|
||||
(sequence_number & 0xff) as u8,
|
||||
]);
|
||||
|
||||
if sequence_number == 0 {
|
||||
let data_len = data.len() + 5;
|
||||
chunk[5..12].copy_from_slice(&[
|
||||
(data_len >> 8) as u8,
|
||||
(data_len & 0xff) as u8,
|
||||
APDU_CLA,
|
||||
command,
|
||||
p1,
|
||||
p2,
|
||||
data.len() as u8,
|
||||
]);
|
||||
}
|
||||
|
||||
chunk[header..header + size].copy_from_slice(&data[offset..offset + size]);
|
||||
}
|
||||
trace!("Ledger write {:?}", &hid_chunk[..]);
|
||||
let n = self.device.write(&hid_chunk[..])?;
|
||||
if n < size + header {
|
||||
return Err(RemoteWalletError::Protocol("Write data size mismatch"));
|
||||
}
|
||||
offset += size;
|
||||
sequence_number += 1;
|
||||
if sequence_number >= 0xffff {
|
||||
return Err(RemoteWalletError::Protocol(
|
||||
"Maximum sequence number reached",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Transport Protocol:
|
||||
// * Communication Channel Id (2 bytes big endian )
|
||||
// * Command Tag (1 byte)
|
||||
// * Packet Sequence ID (2 bytes big endian)
|
||||
// * Payload (Optional)
|
||||
//
|
||||
// Payload
|
||||
// * APDU Total Length (2 bytes big endian)
|
||||
// * APDU_CLA (1 byte)
|
||||
// * APDU_INS (1 byte)
|
||||
// * APDU_P1 (1 byte)
|
||||
// * APDU_P2 (1 byte)
|
||||
// * APDU_LENGTH (1 byte)
|
||||
// * APDU_Payload (Variable)
|
||||
//
|
||||
fn read(&self) -> Result<Vec<u8>, RemoteWalletError> {
|
||||
let mut message_size = 0;
|
||||
let mut message = Vec::new();
|
||||
|
||||
// terminate the loop if `sequence_number` reaches its max_value and report error
|
||||
for chunk_index in 0..=0xffff {
|
||||
let mut chunk: [u8; HID_PACKET_SIZE] = [0; HID_PACKET_SIZE];
|
||||
let chunk_size = self.device.read(&mut chunk)?;
|
||||
trace!("Ledger read {:?}", &chunk[..]);
|
||||
if chunk_size < LEDGER_TRANSPORT_HEADER_LEN
|
||||
|| chunk[0] != 0x01
|
||||
|| chunk[1] != 0x01
|
||||
|| chunk[2] != APDU_TAG
|
||||
{
|
||||
return Err(RemoteWalletError::Protocol("Unexpected chunk header"));
|
||||
}
|
||||
let seq = (chunk[3] as usize) << 8 | (chunk[4] as usize);
|
||||
if seq != chunk_index {
|
||||
return Err(RemoteWalletError::Protocol("Unexpected chunk header"));
|
||||
}
|
||||
|
||||
let mut offset = 5;
|
||||
if seq == 0 {
|
||||
// Read message size and status word.
|
||||
if chunk_size < 7 {
|
||||
return Err(RemoteWalletError::Protocol("Unexpected chunk header"));
|
||||
}
|
||||
message_size = (chunk[5] as usize) << 8 | (chunk[6] as usize);
|
||||
offset += 2;
|
||||
}
|
||||
message.extend_from_slice(&chunk[offset..chunk_size]);
|
||||
message.truncate(message_size);
|
||||
if message.len() == message_size {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if message.len() < 2 {
|
||||
return Err(RemoteWalletError::Protocol("No status word"));
|
||||
}
|
||||
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 {
|
||||
// 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)",
|
||||
)),
|
||||
0x6a80 => Err(RemoteWalletError::Protocol("Invalid data")),
|
||||
0x6a82 => Err(RemoteWalletError::Protocol("File not found")),
|
||||
0x6a85 => Err(RemoteWalletError::UserCancel),
|
||||
0x6b00 => Err(RemoteWalletError::Protocol("Incorrect parameters")),
|
||||
0x6d00 => Err(RemoteWalletError::Protocol(
|
||||
"Not implemented. Make sure the Ledger Solana Wallet app is running.",
|
||||
)),
|
||||
0x6faa => Err(RemoteWalletError::Protocol(
|
||||
"Your Ledger needs to be unplugged",
|
||||
)),
|
||||
0x6f00..=0x6fff => Err(RemoteWalletError::Protocol("Internal error")),
|
||||
0x9000 => Ok(()),
|
||||
_ => Err(RemoteWalletError::Protocol("Unknown error")),
|
||||
}?;
|
||||
let new_len = message.len() - 2;
|
||||
message.truncate(new_len);
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
fn send_apdu(
|
||||
&self,
|
||||
command: u8,
|
||||
p1: u8,
|
||||
p2: u8,
|
||||
data: &[u8],
|
||||
) -> Result<Vec<u8>, RemoteWalletError> {
|
||||
self.write(command, p1, p2, data)?;
|
||||
self.read()
|
||||
}
|
||||
|
||||
fn get_firmware_version(&self) -> Result<FirmwareVersion, RemoteWalletError> {
|
||||
let ver = self.send_apdu(commands::GET_APP_CONFIGURATION, 0, 0, &[])?;
|
||||
if ver.len() != 4 {
|
||||
return Err(RemoteWalletError::Protocol("Version packet size mismatch"));
|
||||
}
|
||||
Ok(FirmwareVersion::new(
|
||||
ver[1].into(),
|
||||
ver[2].into(),
|
||||
ver[3].into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteWallet for LedgerWallet {
|
||||
fn read_device(
|
||||
&self,
|
||||
dev_info: &hidapi::HidDeviceInfo,
|
||||
) -> Result<RemoteWalletInfo, RemoteWalletError> {
|
||||
let manufacturer = dev_info
|
||||
.manufacturer_string
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Unknown".to_owned())
|
||||
.to_lowercase()
|
||||
.replace(" ", "-");
|
||||
let model = dev_info
|
||||
.product_string
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Unknown".to_owned())
|
||||
.to_lowercase()
|
||||
.replace(" ", "-");
|
||||
let serial = dev_info
|
||||
.serial_number
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Unknown".to_owned());
|
||||
self.get_pubkey(DerivationPath::default())
|
||||
.map(|pubkey| RemoteWalletInfo {
|
||||
model,
|
||||
manufacturer,
|
||||
serial,
|
||||
pubkey,
|
||||
})
|
||||
}
|
||||
|
||||
fn get_pubkey(&self, derivation: DerivationPath) -> Result<Pubkey, RemoteWalletError> {
|
||||
let derivation_path = get_derivation_path(derivation);
|
||||
|
||||
let key = self.send_apdu(commands::GET_SOL_PUBKEY, 0, 0, &derivation_path)?;
|
||||
if key.len() != 32 {
|
||||
return Err(RemoteWalletError::Protocol("Key packet size mismatch"));
|
||||
}
|
||||
Ok(Pubkey::new(&key))
|
||||
}
|
||||
|
||||
fn sign_transaction(
|
||||
&self,
|
||||
derivation: DerivationPath,
|
||||
transaction: Transaction,
|
||||
) -> Result<Signature, RemoteWalletError> {
|
||||
let mut chunk = [0_u8; MAX_CHUNK_SIZE];
|
||||
let derivation_path = get_derivation_path(derivation);
|
||||
let data = transaction.message_data();
|
||||
|
||||
let _firmware_version = self.get_firmware_version();
|
||||
|
||||
// Copy the address of the key (only done once)
|
||||
chunk[0..derivation_path.len()].copy_from_slice(&derivation_path);
|
||||
|
||||
let key_length = derivation_path.len();
|
||||
let max_payload_size = MAX_CHUNK_SIZE - key_length;
|
||||
let data_len = data.len();
|
||||
|
||||
let mut result = Vec::new();
|
||||
let mut offset = 0;
|
||||
|
||||
while offset < data_len {
|
||||
let p1 = if offset == 0 { 0 } else { 0x80 };
|
||||
let take = min(max_payload_size, data_len - offset);
|
||||
|
||||
// Fetch piece of data and copy it!
|
||||
{
|
||||
let (_key, d) = &mut chunk.split_at_mut(key_length);
|
||||
let (dst, _rem) = &mut d.split_at_mut(take);
|
||||
dst.copy_from_slice(&data[offset..(offset + take)]);
|
||||
}
|
||||
|
||||
result = self.send_apdu(
|
||||
commands::SIGN_SOL_TRANSACTION,
|
||||
p1,
|
||||
0,
|
||||
&chunk[0..(key_length + take)],
|
||||
)?;
|
||||
offset += take;
|
||||
}
|
||||
|
||||
if result.len() != 64 {
|
||||
return Err(RemoteWalletError::Protocol(
|
||||
"Signature packet size mismatch",
|
||||
));
|
||||
}
|
||||
Ok(Signature::new(&result))
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the detected device is a valid `Ledger device` by checking both the product ID and the vendor ID
|
||||
pub fn is_valid_ledger(vendor_id: u16, product_id: u16) -> bool {
|
||||
vendor_id == LEDGER_VID
|
||||
&& (LEDGER_NANO_S_PIDS.contains(&product_id) || LEDGER_NANO_X_PIDS.contains(&product_id))
|
||||
}
|
||||
|
||||
/// Build the derivation path byte array from a DerivationPath selection
|
||||
fn get_derivation_path(derivation: DerivationPath) -> Vec<u8> {
|
||||
let byte = if derivation.change.is_some() { 4 } else { 3 };
|
||||
let mut concat_derivation = vec![byte];
|
||||
concat_derivation.extend_from_slice(&SOL_DERIVATION_PATH_BE);
|
||||
concat_derivation.extend_from_slice(&[0x80, 0]);
|
||||
concat_derivation.extend_from_slice(&derivation.account.to_be_bytes());
|
||||
if let Some(change) = derivation.change {
|
||||
concat_derivation.extend_from_slice(&[0x80, 0]);
|
||||
concat_derivation.extend_from_slice(&change.to_be_bytes());
|
||||
}
|
||||
concat_derivation
|
||||
}
|
||||
|
||||
/// Choose a Ledger wallet based on matching info fields
|
||||
pub fn get_ledger_from_info(
|
||||
info: RemoteWalletInfo,
|
||||
) -> Result<Arc<LedgerWallet>, 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<Pubkey>, Vec<String>) = devices
|
||||
.iter()
|
||||
.filter(|&device_info| device_info.matches(&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::*;
|
||||
use crate::remote_wallet::initialize_wallet_manager;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// This test can't be run without an actual ledger device connected with the `Ledger Wallet Solana application` running
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn ledger_pubkey_test() {
|
||||
let wallet_manager = initialize_wallet_manager();
|
||||
|
||||
// Update device list
|
||||
wallet_manager.update_devices().expect("No Ledger found, make sure you have a unlocked Ledger connected with the Ledger Wallet Solana running");
|
||||
assert!(wallet_manager.list_devices().len() > 0);
|
||||
|
||||
// Fetch the base pubkey of a connected ledger device
|
||||
let ledger_base_pubkey = wallet_manager
|
||||
.list_devices()
|
||||
.iter()
|
||||
.filter(|d| d.manufacturer == "ledger".to_string())
|
||||
.nth(0)
|
||||
.map(|d| d.pubkey.clone())
|
||||
.expect("No ledger device detected");
|
||||
|
||||
let ledger = wallet_manager
|
||||
.get_ledger(&ledger_base_pubkey)
|
||||
.expect("get device");
|
||||
|
||||
let mut pubkey_set = HashSet::new();
|
||||
pubkey_set.insert(ledger_base_pubkey);
|
||||
|
||||
let pubkey_0_0 = ledger
|
||||
.get_pubkey(DerivationPath {
|
||||
account: 0,
|
||||
change: Some(0),
|
||||
})
|
||||
.expect("get pubkey");
|
||||
pubkey_set.insert(pubkey_0_0);
|
||||
let pubkey_0_1 = ledger
|
||||
.get_pubkey(DerivationPath {
|
||||
account: 0,
|
||||
change: Some(1),
|
||||
})
|
||||
.expect("get pubkey");
|
||||
pubkey_set.insert(pubkey_0_1);
|
||||
let pubkey_1 = ledger
|
||||
.get_pubkey(DerivationPath {
|
||||
account: 1,
|
||||
change: None,
|
||||
})
|
||||
.expect("get pubkey");
|
||||
pubkey_set.insert(pubkey_1);
|
||||
let pubkey_1_0 = ledger
|
||||
.get_pubkey(DerivationPath {
|
||||
account: 1,
|
||||
change: Some(0),
|
||||
})
|
||||
.expect("get pubkey");
|
||||
pubkey_set.insert(pubkey_1_0);
|
||||
|
||||
assert_eq!(pubkey_set.len(), 5); // Ensure keys at various derivation paths are unique
|
||||
}
|
||||
}
|
2
remote-wallet/src/lib.rs
Normal file
2
remote-wallet/src/lib.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod ledger;
|
||||
pub mod remote_wallet;
|
358
remote-wallet/src/remote_wallet.rs
Normal file
358
remote-wallet/src/remote_wallet.rs
Normal file
@ -0,0 +1,358 @@
|
||||
use crate::ledger::{is_valid_ledger, LedgerWallet};
|
||||
use log::*;
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use solana_sdk::{pubkey::Pubkey, signature::Signature, transaction::Transaction};
|
||||
use std::{
|
||||
fmt,
|
||||
str::FromStr,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
const HID_GLOBAL_USAGE_PAGE: u16 = 0xFF00;
|
||||
const HID_USB_DEVICE_CLASS: u8 = 0;
|
||||
|
||||
/// Remote wallet error.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum RemoteWalletError {
|
||||
#[error("hidapi error")]
|
||||
Hid(#[from] hidapi::HidError),
|
||||
|
||||
#[error("device type mismatch")]
|
||||
DeviceTypeMismatch,
|
||||
|
||||
#[error("device with non-supported product ID or vendor ID was detected")]
|
||||
InvalidDevice,
|
||||
|
||||
#[error("invalid derivation path: {0}")]
|
||||
InvalidDerivationPath(String),
|
||||
|
||||
#[error("invalid path: {0}")]
|
||||
InvalidPath(String),
|
||||
|
||||
#[error("no device found")]
|
||||
NoDeviceFound,
|
||||
|
||||
#[error("protocol error: {0}")]
|
||||
Protocol(&'static str),
|
||||
|
||||
#[error("pubkey not found for given address")]
|
||||
PubkeyNotFound,
|
||||
|
||||
#[error("operation has been cancelled")]
|
||||
UserCancel,
|
||||
}
|
||||
|
||||
/// Collection of conntected RemoteWallets
|
||||
pub struct RemoteWalletManager {
|
||||
usb: Arc<Mutex<hidapi::HidApi>>,
|
||||
devices: RwLock<Vec<Device>>,
|
||||
}
|
||||
|
||||
impl RemoteWalletManager {
|
||||
/// Create a new instance.
|
||||
pub fn new(usb: Arc<Mutex<hidapi::HidApi>>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
usb,
|
||||
devices: RwLock::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Repopulate device list
|
||||
/// Note: this method iterates over and updates all devices
|
||||
pub fn update_devices(&self) -> Result<usize, RemoteWalletError> {
|
||||
let mut usb = self.usb.lock();
|
||||
usb.refresh_devices()?;
|
||||
let devices = usb.devices();
|
||||
let num_prev_devices = self.devices.read().len();
|
||||
|
||||
let detected_devices = devices
|
||||
.iter()
|
||||
.filter(|&device_info| {
|
||||
is_valid_hid_device(device_info.usage_page, device_info.interface_number)
|
||||
})
|
||||
.fold(Vec::new(), |mut v, device_info| {
|
||||
if is_valid_ledger(device_info.vendor_id, device_info.product_id) {
|
||||
match usb.open_path(&device_info.path) {
|
||||
Ok(device) => {
|
||||
let ledger = LedgerWallet::new(device);
|
||||
if let Ok(info) = ledger.read_device(&device_info) {
|
||||
let path = device_info.path.to_str().unwrap().to_string();
|
||||
trace!("Found device: {:?}", info);
|
||||
v.push(Device {
|
||||
path,
|
||||
info,
|
||||
wallet_type: RemoteWalletType::Ledger(Arc::new(ledger)),
|
||||
})
|
||||
}
|
||||
}
|
||||
Err(e) => error!("Error connecting to ledger device to read info: {}", e),
|
||||
}
|
||||
}
|
||||
v
|
||||
});
|
||||
|
||||
let num_curr_devices = detected_devices.len();
|
||||
*self.devices.write() = detected_devices;
|
||||
|
||||
Ok(num_curr_devices - num_prev_devices)
|
||||
}
|
||||
|
||||
/// List connected and acknowledged wallets
|
||||
pub fn list_devices(&self) -> Vec<RemoteWalletInfo> {
|
||||
self.devices.read().iter().map(|d| d.info.clone()).collect()
|
||||
}
|
||||
|
||||
/// Get a particular wallet
|
||||
#[allow(unreachable_patterns)]
|
||||
pub fn get_ledger(&self, pubkey: &Pubkey) -> Result<Arc<LedgerWallet>, RemoteWalletError> {
|
||||
self.devices
|
||||
.read()
|
||||
.iter()
|
||||
.find(|device| &device.info.pubkey == pubkey)
|
||||
.ok_or(RemoteWalletError::PubkeyNotFound)
|
||||
.and_then(|device| match &device.wallet_type {
|
||||
RemoteWalletType::Ledger(ledger) => Ok(ledger.clone()),
|
||||
_ => Err(RemoteWalletError::DeviceTypeMismatch),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get wallet info.
|
||||
pub fn get_wallet_info(&self, pubkey: &Pubkey) -> Option<RemoteWalletInfo> {
|
||||
self.devices
|
||||
.read()
|
||||
.iter()
|
||||
.find(|d| &d.info.pubkey == pubkey)
|
||||
.map(|d| d.info.clone())
|
||||
}
|
||||
|
||||
/// Update devices in maximum `max_polling_duration` if it doesn't succeed
|
||||
pub fn try_connect_polling(&self, max_polling_duration: &Duration) -> bool {
|
||||
let start_time = Instant::now();
|
||||
while start_time.elapsed() <= *max_polling_duration {
|
||||
if let Ok(num_devices) = self.update_devices() {
|
||||
let plural = if num_devices == 1 { "" } else { "s" };
|
||||
trace!("{} Remote Wallet{} found", num_devices, plural);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// `RemoteWallet` trait
|
||||
pub trait RemoteWallet {
|
||||
/// Parse device info and get device base pubkey
|
||||
fn read_device(
|
||||
&self,
|
||||
dev_info: &hidapi::HidDeviceInfo,
|
||||
) -> Result<RemoteWalletInfo, RemoteWalletError>;
|
||||
|
||||
/// Get solana pubkey from a RemoteWallet
|
||||
fn get_pubkey(&self, derivation: DerivationPath) -> Result<Pubkey, RemoteWalletError>;
|
||||
|
||||
/// Sign transaction data with wallet managing pubkey at derivation path m/44'/501'/<account>'/<change>'.
|
||||
fn sign_transaction(
|
||||
&self,
|
||||
derivation: DerivationPath,
|
||||
transaction: Transaction,
|
||||
) -> Result<Signature, RemoteWalletError>;
|
||||
}
|
||||
|
||||
/// `RemoteWallet` device
|
||||
#[derive(Debug)]
|
||||
pub struct Device {
|
||||
pub(crate) path: String,
|
||||
pub(crate) info: RemoteWalletInfo,
|
||||
pub wallet_type: RemoteWalletType,
|
||||
}
|
||||
|
||||
/// Remote wallet convenience enum to hold various wallet types
|
||||
#[derive(Debug)]
|
||||
pub enum RemoteWalletType {
|
||||
Ledger(Arc<LedgerWallet>),
|
||||
}
|
||||
|
||||
/// Remote wallet information.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct RemoteWalletInfo {
|
||||
/// RemoteWallet device model
|
||||
pub model: String,
|
||||
/// RemoteWallet device manufacturer
|
||||
pub manufacturer: String,
|
||||
/// RemoteWallet device serial number
|
||||
pub serial: String,
|
||||
/// Base pubkey of device at Solana derivation path
|
||||
pub pubkey: Pubkey,
|
||||
}
|
||||
|
||||
impl RemoteWalletInfo {
|
||||
pub fn parse_path(mut path: String) -> Result<(Self, DerivationPath), RemoteWalletError> {
|
||||
let mut path = path.split_off(6);
|
||||
if path.ends_with('/') {
|
||||
path.pop();
|
||||
}
|
||||
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 mut derivation_path = DerivationPath::default();
|
||||
if let Some(purpose) = parts.next() {
|
||||
if purpose.replace("'", "") != "44" {
|
||||
return Err(RemoteWalletError::InvalidDerivationPath(format!(
|
||||
"Incorrect purpose number, found: {}, must be 44",
|
||||
purpose
|
||||
)));
|
||||
}
|
||||
if let Some(coin) = parts.next() {
|
||||
if coin.replace("'", "") != "501" {
|
||||
return Err(RemoteWalletError::InvalidDerivationPath(format!(
|
||||
"Incorrect coin number, found: {}, must be 501",
|
||||
coin
|
||||
)));
|
||||
}
|
||||
if let Some(account) = parts.next() {
|
||||
derivation_path.account = account.replace("'", "").parse::<u16>().unwrap();
|
||||
derivation_path.change = parts
|
||||
.next()
|
||||
.and_then(|change| change.replace("'", "").parse::<u16>().ok());
|
||||
}
|
||||
} else {
|
||||
return Err(RemoteWalletError::InvalidDerivationPath(
|
||||
"Derivation path too short, missing coin number".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok((wallet_info, derivation_path))
|
||||
}
|
||||
|
||||
pub fn get_pretty_path(&self) -> String {
|
||||
format!(
|
||||
"usb://{}/{}/{:?}",
|
||||
self.manufacturer, self.model, self.pubkey,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn matches(&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())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, PartialEq, Clone)]
|
||||
pub struct DerivationPath {
|
||||
pub account: u16,
|
||||
pub change: Option<u16>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for DerivationPath {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let change = if let Some(change) = self.change {
|
||||
format!("/{:?}'", change)
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
write!(f, "m/44'/501'/{:?}'{}", self.account, change)
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to determine if a device is a valid HID
|
||||
pub fn is_valid_hid_device(usage_page: u16, interface_number: i32) -> bool {
|
||||
usage_page == HID_GLOBAL_USAGE_PAGE || interface_number == HID_USB_DEVICE_CLASS as i32
|
||||
}
|
||||
|
||||
/// Helper to initialize hidapi and RemoteWalletManager
|
||||
pub fn initialize_wallet_manager() -> Arc<RemoteWalletManager> {
|
||||
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();
|
||||
let (wallet_info, derivation_path) =
|
||||
RemoteWalletInfo::parse_path(format!("usb://ledger/nano-s/{:?}/44/501/1/2", pubkey))
|
||||
.unwrap();
|
||||
assert!(wallet_info.matches(&RemoteWalletInfo {
|
||||
model: "nano-s".to_string(),
|
||||
manufacturer: "ledger".to_string(),
|
||||
serial: "".to_string(),
|
||||
pubkey,
|
||||
}));
|
||||
assert_eq!(
|
||||
derivation_path,
|
||||
DerivationPath {
|
||||
account: 1,
|
||||
change: Some(2),
|
||||
}
|
||||
);
|
||||
let (wallet_info, derivation_path) = RemoteWalletInfo::parse_path(format!(
|
||||
"usb://ledger/nano-s/{:?}/44'/501'/1'/2'",
|
||||
pubkey
|
||||
))
|
||||
.unwrap();
|
||||
assert!(wallet_info.matches(&RemoteWalletInfo {
|
||||
model: "nano-s".to_string(),
|
||||
manufacturer: "ledger".to_string(),
|
||||
serial: "".to_string(),
|
||||
pubkey,
|
||||
}));
|
||||
assert_eq!(
|
||||
derivation_path,
|
||||
DerivationPath {
|
||||
account: 1,
|
||||
change: Some(2),
|
||||
}
|
||||
);
|
||||
|
||||
assert!(RemoteWalletInfo::parse_path(format!(
|
||||
"usb://ledger/nano-s/{:?}/43/501/1/2",
|
||||
pubkey
|
||||
))
|
||||
.is_err());
|
||||
assert!(RemoteWalletInfo::parse_path(format!(
|
||||
"usb://ledger/nano-s/{:?}/44/500/1/2",
|
||||
pubkey
|
||||
))
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remote_wallet_info_matches() {
|
||||
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!(!info.matches(&test_info));
|
||||
test_info.manufacturer = "Ledger".to_string();
|
||||
assert!(info.matches(&test_info));
|
||||
test_info.model = "Other".to_string();
|
||||
assert!(!info.matches(&test_info));
|
||||
test_info.model = "Nano S".to_string();
|
||||
assert!(info.matches(&test_info));
|
||||
let another_pubkey = Pubkey::new_rand();
|
||||
test_info.pubkey = another_pubkey;
|
||||
assert!(!info.matches(&test_info));
|
||||
test_info.pubkey = pubkey;
|
||||
assert!(info.matches(&test_info));
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user