diff --git a/Cargo.lock b/Cargo.lock index 76a5af6..ca7d209 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -948,8 +948,10 @@ dependencies = [ "alloy", "alloy-primitives", "anyhow", + "dialoguer", "dirs", "reqwest", + "rpassword", "serde", "serde_json", "structopt", @@ -967,10 +969,23 @@ dependencies = [ "bitflags 1.3.2", "strsim", "textwrap", - "unicode-width", + "unicode-width 0.1.13", "vec_map", ] +[[package]] +name = "console" +version = "0.15.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea3c6ecd8059b57859df5c69830340ed3c41d30e3da0c1cbed90a96ac853041b" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.0", + "windows-sys 0.59.0", +] + [[package]] name = "const-hex" version = "1.12.0" @@ -1111,6 +1126,19 @@ dependencies = [ "syn 2.0.70", ] +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror", + "zeroize", +] + [[package]] name = "digest" version = "0.9.0" @@ -1204,6 +1232,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -2407,6 +2441,27 @@ dependencies = [ "rustc-hex", ] +[[package]] +name = "rpassword" +version = "7.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80472be3c897911d0137b2d2b9055faf6eeac5b14e324073d83bc17b191d7e3f" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.48.0", +] + +[[package]] +name = "rtoolbox" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c247d24e63230cdb56463ae328478bd5eac8b8faa8c69461a77e8e323afac90e" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + [[package]] name = "ruint" version = "1.12.3" @@ -2702,6 +2757,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -2926,7 +2987,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" dependencies = [ - "unicode-width", + "unicode-width 0.1.13", ] [[package]] @@ -3271,6 +3332,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "untrusted" version = "0.9.0" @@ -3503,6 +3570,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-targets" version = "0.48.5" diff --git a/Cargo.toml b/Cargo.toml index f484a86..c1a55cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,8 +7,10 @@ edition = "2021" alloy = { version = "0.1.4", features = ["full"] } alloy-primitives = "0.7.7" anyhow = "1.0.86" +dialoguer = "0.11.0" dirs = "5.0.1" reqwest = "0.12.9" +rpassword = "7.3.1" serde = "1.0.204" serde_json = "1.0.120" structopt = "0.3.26" diff --git a/src/chainlist.rs b/src/chainlist.rs index acd2116..f369ac3 100644 --- a/src/chainlist.rs +++ b/src/chainlist.rs @@ -9,12 +9,16 @@ pub struct ChainlistEntry { pub rpc: Vec, } +pub async fn fetch_all_chains() -> Result> { + let url = "https://chainid.network/chains.json"; + Ok(reqwest::get(url).await?.json().await?) +} + pub async fn fetch_chain_data( chain_id: Option, name: Option, ) -> Result { - let url = "https://chainid.network/chains.json"; - let chains: Vec = reqwest::get(url).await?.json().await?; + let chains = fetch_all_chains().await?; let chain = if let Some(id) = chain_id { chains.into_iter().find(|c| c.chain_id == id) diff --git a/src/config.rs b/src/config.rs index b8c86fd..f19c219 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,13 +13,14 @@ use std::path::PathBuf; pub const CONFIG_FILE_LOCATION: &str = ".chainz.json"; pub const DEFAULT_ENV_PREFIX: &str = "FOUNDRY"; +pub const DEFAULT_KEY_NAME: &str = "default"; #[derive(Serialize, Deserialize)] pub struct ChainzConfig { - pub default_private_key: String, pub env_prefix: String, pub chains: Vec, pub variables: HashMap, + pub keys: HashMap, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -28,8 +29,10 @@ pub struct ChainConfig { pub chain_id: u64, // sorted by order to attempt pub rpc_urls: Vec, + // stores the last known working RPC URL + pub last_working_rpc: Option, pub verification_api_key: Option, - pub private_key: Option, + pub key_name: Option, } pub struct Chain { @@ -43,24 +46,34 @@ pub struct Chain { impl Default for ChainzConfig { fn default() -> Self { // generate a random private key as default - let signer = PrivateKeySigner::random(); - let private_key = signer.to_bytes().to_string(); Self { - default_private_key: private_key, env_prefix: DEFAULT_ENV_PREFIX.to_string(), chains: vec![], variables: HashMap::new(), + keys: HashMap::new(), } } } impl ChainzConfig { - pub fn set_default_private_key(&mut self, default_private_key: String) { - self.default_private_key = default_private_key + pub async fn add_key(&mut self, name: &str, key: &str) -> Result<()> { + if self.keys.contains_key(name) { + anyhow::bail!("Key '{}' already exists", name); + } + self.keys.insert(name.to_string(), key.to_string()); + Ok(()) + } + + pub async fn list_keys(&self) -> Result> { + Ok(self.keys.keys().cloned().collect()) } - pub fn set_default_env_prefix(&mut self, env_prefix: String) { - self.env_prefix = env_prefix + pub async fn remove_key(&mut self, name: &str) -> Result<()> { + if !self.keys.contains_key(name) { + anyhow::bail!("Key '{}' not found", name); + } + self.keys.remove(name); + Ok(()) } pub async fn get_chain_by_name(&self, name: &str) -> Result { @@ -82,13 +95,14 @@ impl ChainzConfig { } // get a chain from a chain config - async fn get_chain(&self, config: &ChainConfig) -> Result { + pub async fn get_chain(&self, config: &ChainConfig) -> Result { let rpc_url = self.get_rpc(config).await?; let provider = create_provider(&rpc_url).await?; - let private_key = config - .private_key + let key_name = config + .key_name .clone() - .unwrap_or(self.default_private_key.clone()); + .unwrap_or(DEFAULT_KEY_NAME.to_string()); + let private_key = self.get_key(&key_name)?; let signer = private_key.parse::()?; Ok(Chain { config: config.clone(), @@ -99,36 +113,45 @@ impl ChainzConfig { }) } + fn get_key(&self, key_name: &str) -> Result { + self.keys + .get(key_name) + .cloned() + .ok_or(anyhow!("Key '{}' not found", key_name)) + } + // get the first rpc url that returns the correct chain id async fn get_rpc(&self, config: &ChainConfig) -> Result { - // try RPC urls one by one - // injecting environment variables if needed - // returning the first one that successfully returns chainid + // First try the last working RPC if available + if let Some(last_working) = &config.last_working_rpc { + if let Some(rpc_url) = test_rpc(last_working, config.chain_id, &self.variables).await { + return Ok(rpc_url); + } + } + + // If last working RPC failed or doesn't exist, try others for rpc_url in &config.rpc_urls { - // Interpolate environment variables in the RPC URL - let interpolated_url = interpolate_variables(rpc_url, &self.variables); - if let Ok(provider) = create_provider(&interpolated_url).await { - // ensure it equals config.chainId - if let Ok(chain_id) = provider.get_chain_id().await { - if chain_id == config.chain_id { - return Ok(interpolated_url); - } - } + if let Some(rpc_url) = test_rpc(rpc_url, config.chain_id, &self.variables).await { + return Ok(rpc_url); } } + Err(anyhow!("No valid RPC urls found")) } - // get all chains + // get all chains, skipping ones that fail to load pub async fn get_chains(&self) -> Result> { let mut chains = vec![]; for chain in &self.chains { - chains.push(self.get_chain(chain).await?); + match self.get_chain(chain).await { + Ok(chain) => chains.push(chain), + Err(e) => eprintln!("Failed to load chain {}: {}", chain.name, e), + } } Ok(chains) } - pub async fn add_chain(&mut self, args: &AddArgs) -> Result { + pub async fn add_chain(&mut self, args: &AddArgs) -> Result { let chain = ChainConfig::from_add_args(args).await?; // print // update chain if it already exists @@ -137,7 +160,7 @@ impl ChainzConfig { } else { self.chains.push(chain.clone()); } - self.get_chain_by_name(&chain.name).await + Ok(chain) } pub async fn write(&self) -> Result<()> { @@ -150,6 +173,12 @@ impl ChainzConfig { Ok(()) } + pub async fn delete() -> Result<()> { + tokio::fs::remove_file(get_config_path().ok_or(anyhow!("Unable to find config path"))?) + .await?; + Ok(()) + } + pub async fn load() -> Result { let json = tokio::fs::read_to_string( get_config_path().ok_or(anyhow!("Unable to find config path"))?, @@ -175,6 +204,7 @@ impl ChainConfig { Ok(Self { name, chain_id, + last_working_rpc: None, // given rpc url is first in list to try if given rpc_urls: match &args.rpc_url { Some(rpc_url) => { @@ -185,7 +215,7 @@ impl ChainConfig { None => chain_data.rpc, }, verification_api_key: args.verification_api_key.clone(), - private_key: args.private_key.clone(), + key_name: args.key_name.clone(), }) } } @@ -280,6 +310,27 @@ fn find_next_var(input: &str) -> Option<(usize, usize)> { Some((start, end)) } +pub async fn config_exists() -> Result { + Ok(get_config_path().map(|p| p.exists()).unwrap_or(false)) +} + +async fn test_rpc( + rpc_url: &str, + expected_chain_id: u64, + variables: &HashMap, +) -> Option { + // First try the last working RPC if available + let interpolated_url = interpolate_variables(rpc_url, variables); + if let Ok(provider) = create_provider(&interpolated_url).await { + if let Ok(chain_id) = provider.get_chain_id().await { + if chain_id == expected_chain_id { + return Some(interpolated_url); + } + } + } + None +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/init.rs b/src/init.rs new file mode 100644 index 0000000..3508a79 --- /dev/null +++ b/src/init.rs @@ -0,0 +1,97 @@ +// module for storing configurations of encrypted private keys + +use crate::{ + chainlist::fetch_all_chains, + config::{config_exists, ChainzConfig, DEFAULT_ENV_PREFIX, DEFAULT_KEY_NAME}, + opt, +}; +use anyhow::Result; +use dialoguer::{Confirm, Input, MultiSelect}; + +const INFURA_API_KEY_ENV_VAR: &str = "INFURA_API_KEY"; +static DEFAULT_INIT_CHAINS: &[u64] = &[ + 1, 56, 8453, 42161, 43114, 137, 130, 1301, 10, 81457, 59144, 100, 167000, 534352, 11155111, +]; + +pub async fn handle_init() -> Result<()> { + if config_exists().await? { + let overwrite = Confirm::new() + .with_prompt("Configuration already exists. Overwrite?") + .interact()?; + if !overwrite { + println!("Aborting initialization"); + return Ok(()); + } + ChainzConfig::delete().await?; + } + + let chainz = initialize_with_wizard().await?; + + chainz.write().await?; + println!("Configuration initialized successfully!"); + Ok(()) +} + +async fn initialize_with_wizard() -> Result { + println!("Chainz Init"); + let mut config = ChainzConfig::default(); + + // Configure environment prefix + let env_prefix: String = Input::new() + .with_prompt("Environment variable prefix") + .default(DEFAULT_ENV_PREFIX.to_string()) + .interact_text()?; + config.env_prefix = env_prefix; + + // TODO: allow generate in place + let private_key = rpassword::prompt_password("Enter default private key: ")?; + config.add_key(DEFAULT_KEY_NAME, &private_key).await?; + + // get infura_api_key, optionally + let infura_api_key: String = Input::new() + .with_prompt("Infura API Key (optional)") + .allow_empty(true) + .interact_text()?; + if !infura_api_key.is_empty() { + config + .variables + .insert(INFURA_API_KEY_ENV_VAR.to_string(), infura_api_key); + } + + // Select chains to add + // TODO: fzf? + let available_chains = fetch_all_chains() + .await? + .into_iter() + .map(|c| (c.name, c.chain_id)) + .filter(|(_, id)| DEFAULT_INIT_CHAINS.contains(id)) + .collect::>(); + + let selections = MultiSelect::new() + .with_prompt("Select chains to configure") + .items( + &available_chains + .iter() + .map(|(name, _)| name) + .collect::>(), + ) + .interact()?; + + for &idx in selections.iter() { + let (name, chain_id) = &available_chains[idx]; + let args = opt::AddArgs { + name: Some(name.to_lowercase().replace(" ", "_")), + chain_id: Some(chain_id.clone()), + rpc_url: None, + verification_api_key: None, + // TODO: allow key override + key_name: None, + }; + match config.add_chain(&args).await { + Ok(_) => println!("Added {}", name), + Err(e) => println!("Failed to add {}: {}", name, e), + } + } + + Ok(config) +} diff --git a/src/key.rs b/src/key.rs new file mode 100644 index 0000000..fd77ace --- /dev/null +++ b/src/key.rs @@ -0,0 +1,37 @@ +// module for storing configurations of encrypted private keys + +use crate::{config::ChainzConfig, opt::KeyCommand}; +use anyhow::Result; + +// TODO: encrypt keys +pub async fn handle_key_command(mut chainz: ChainzConfig, cmd: KeyCommand) -> Result<()> { + match cmd { + KeyCommand::Add { name, key } => { + let key = if let Some(k) = key { + k + } else { + rpassword::prompt_password("Enter private key: ")? + }; + chainz.add_key(&name, &key).await?; + println!("Added key '{}'", name); + chainz.write().await?; + } + KeyCommand::List => { + let keys = chainz.list_keys().await?; + if keys.is_empty() { + println!("No stored keys"); + } else { + println!("Stored keys:"); + for name in keys { + println!("- {}", name); + } + } + } + KeyCommand::Remove { name } => { + chainz.remove_key(&name).await?; + println!("Removed key '{}'", name); + chainz.write().await?; + } + } + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index af63d68..59cb87c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,8 @@ use structopt::StructOpt; pub mod chainlist; pub mod config; +pub mod init; +pub mod key; pub mod opt; use config::{Chain, ChainzConfig}; use opt::Opt; @@ -20,10 +22,12 @@ async fn main() -> Result<()> { .await .unwrap_or_else(|_| ChainzConfig::default()); match opts.cmd { + opt::Command::Init {} => init::handle_init().await?, + opt::Command::Key { cmd } => key::handle_key_command(chainz, cmd).await?, opt::Command::Add { args } => { - let chain = chainz.add_chain(&args).await?; - println!("Added chain {}", chain.config.name); - print_chain(&chain).await?; + let config = chainz.add_chain(&args).await?; + println!("Added chain {}", config.name); + print_chain(&chainz.get_chain(&config).await?).await?; chainz.write().await?; } opt::Command::List => { @@ -31,18 +35,6 @@ async fn main() -> Result<()> { print_chain(chain).await?; } } - opt::Command::Set { - default_private_key, - env_prefix, - } => { - if let Some(env_prefix) = env_prefix { - chainz.set_default_env_prefix(env_prefix); - } - if let Some(default_private_key) = default_private_key { - chainz.set_default_private_key(default_private_key); - } - chainz.write().await?; - } opt::Command::Use { name_or_id, print } => { // try parse as a u64 id, else use as name let chain = match name_or_id.parse::() { diff --git a/src/opt.rs b/src/opt.rs index 8d5c84c..9208806 100644 --- a/src/opt.rs +++ b/src/opt.rs @@ -13,13 +13,8 @@ pub struct Opt { #[derive(Debug, StructOpt)] #[structopt(about = "Subcommands for chainz")] pub enum Command { - #[structopt(about = "Set a global config parameter")] - Set { - #[structopt(short, long)] - default_private_key: Option, - #[structopt(short, long)] - env_prefix: Option, - }, + /// Initialize a new configuration with wizard + Init {}, #[structopt(about = "Add a new chain")] Add { #[structopt(flatten)] @@ -36,6 +31,30 @@ pub enum Command { }, #[structopt(about = "List all chains")] List, + #[structopt(about = "Manage Private Keys")] + Key { + #[structopt(subcommand)] + cmd: KeyCommand, + }, +} + +#[derive(Debug, StructOpt)] +pub enum KeyCommand { + /// Add a new private key + Add { + /// Name for the private key + name: String, + /// The private key (will prompt if not provided) + #[structopt(long)] + key: Option, + }, + /// List all stored private keys + List, + /// Remove a private key + Remove { + /// Name of the private key to remove + name: String, + }, } #[derive(Debug, StructOpt)] @@ -49,5 +68,5 @@ pub struct AddArgs { #[structopt(short, long)] pub verification_api_key: Option, #[structopt(short, long)] - pub private_key: Option, + pub key_name: Option, }