use anyhow::Result;
use rand::TryRng;
use rand::rngs::SysRng;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use thru_base::tn_tools::Pubkey;
use url::Url;
use crate::error::{CliError, ConfigError};
#[derive(Debug, Clone)]
pub struct KeyManager {
keys: HashMap<String, String>,
}
impl Serialize for KeyManager {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.keys.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for KeyManager {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let keys = HashMap::deserialize(deserializer)?;
Ok(KeyManager { keys })
}
}
impl KeyManager {
pub fn new() -> Self {
let mut keys = HashMap::new();
let mut private_key_bytes = [0u8; 32];
let mut rng = SysRng;
rng.try_fill_bytes(&mut private_key_bytes).unwrap();
let private_key_hex = hex::encode(private_key_bytes);
keys.insert("default".to_string(), private_key_hex);
Self { keys }
}
pub fn list_keys(&self) -> Vec<String> {
let mut key_names: Vec<String> = self.keys.keys().cloned().collect();
key_names.sort();
key_names
}
pub fn add_key(&mut self, name: &str, key: &str, overwrite: bool) -> Result<(), CliError> {
let normalized_name = Self::normalize_key_name(name);
if key.len() != 64 {
return Err(CliError::Validation(
"Key must be exactly 64 hexadecimal characters".to_string(),
));
}
hex::decode(key)
.map_err(|_| CliError::Validation("Invalid hexadecimal key format".to_string()))?;
if self.keys.contains_key(&normalized_name) && !overwrite {
return Err(CliError::Validation(format!(
"Key '{}' already exists. Use --overwrite to replace it",
normalized_name
)));
}
self.keys.insert(normalized_name, key.to_string());
Ok(())
}
pub fn get_key(&self, name: &str) -> Result<&str, CliError> {
let normalized_name = Self::normalize_key_name(name);
self.keys
.get(&normalized_name)
.map(|s| s.as_str())
.ok_or_else(|| CliError::Validation(format!("Key '{}' not found", normalized_name)))
}
pub fn generate_key(&mut self, name: &str, overwrite: bool) -> Result<String, CliError> {
let normalized_name = Self::normalize_key_name(name);
if self.keys.contains_key(&normalized_name) && !overwrite {
return Err(CliError::Validation(format!(
"Key '{}' already exists. Use --overwrite to replace it",
normalized_name
)));
}
let mut private_key_bytes = [0u8; 32];
let mut rng = SysRng;
rng.try_fill_bytes(&mut private_key_bytes).unwrap();
let private_key_hex = hex::encode(private_key_bytes);
self.keys.insert(normalized_name, private_key_hex.clone());
Ok(private_key_hex)
}
pub fn remove_key(&mut self, name: &str) -> Result<(), CliError> {
let normalized_name = Self::normalize_key_name(name);
if !self.keys.contains_key(&normalized_name) {
return Err(CliError::Validation(format!(
"Key '{}' not found",
normalized_name
)));
}
self.keys.remove(&normalized_name);
Ok(())
}
#[allow(dead_code)]
pub fn has_key(&self, name: &str) -> bool {
let normalized_name = Self::normalize_key_name(name);
self.keys.contains_key(&normalized_name)
}
pub fn get_default_key(&self) -> Result<&str, CliError> {
self.get_key("default")
}
fn normalize_key_name(name: &str) -> String {
name.to_lowercase()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkConfig {
pub url: String,
pub auth_token: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub rpc_base_url: String,
pub keys: KeyManager,
pub uploader_program_public_key: String,
pub manager_program_public_key: String,
pub abi_manager_program_public_key: String,
pub token_program_public_key: String,
pub consensus_validator_program_public_key: String,
pub consensus_attestor_table_public_key: String,
pub consensus_converted_vault_public_key: String,
pub consensus_unclaimed_vault_public_key: String,
pub wthru_program_public_key: String,
pub name_service_program_public_key: String,
pub thru_registrar_program_public_key: String,
pub timeout_seconds: u64,
pub max_retries: u32,
pub auth_token: Option<String>,
pub toolchain_path: Option<PathBuf>,
pub toolchain_version: Option<String>,
pub sdk_paths: Option<std::collections::HashMap<String, PathBuf>>,
pub sdk_versions: Option<std::collections::HashMap<String, String>>,
pub github_repo: Option<String>,
#[serde(default)]
pub networks: HashMap<String, NetworkConfig>,
#[serde(default)]
pub default_network: Option<String>,
}
impl Default for Config {
fn default() -> Self {
Self {
rpc_base_url: "https://grpc.alphanet.thruput.org".to_string(),
keys: KeyManager::new(),
uploader_program_public_key: "taAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIC"
.to_string(),
manager_program_public_key: "taAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQE"
.to_string(),
abi_manager_program_public_key: "taAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACrG7"
.to_string(),
token_program_public_key: "taAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKqq".to_string(),
consensus_validator_program_public_key:
"taAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAEN".to_string(),
consensus_attestor_table_public_key: "taAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAIO"
.to_string(),
consensus_converted_vault_public_key: "taAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAQQ"
.to_string(),
consensus_unclaimed_vault_public_key: "taAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAUR"
.to_string(),
wthru_program_public_key: "taAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcH".to_string(),
name_service_program_public_key: "taAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUF"
.to_string(),
thru_registrar_program_public_key: "taAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYG"
.to_string(),
timeout_seconds: 30,
max_retries: 3,
auth_token: None,
toolchain_path: None,
toolchain_version: None,
sdk_paths: None,
sdk_versions: None,
github_repo: None,
networks: HashMap::new(),
default_network: None,
}
}
}
impl Config {
pub async fn load() -> Result<Self, CliError> {
let config_path = Self::get_config_path()?;
if !config_path.exists() {
Self::create_default_config().await?;
}
let config_content = tokio::fs::read_to_string(&config_path).await?;
let config: Config = match serde_yaml::from_str(&config_content) {
Ok(c) => c,
Err(e) => {
let error_msg = e.to_string();
if error_msg.contains("missing field") {
Self::migrate_config(&config_path, &config_content).await?
} else {
return Err(ConfigError::InvalidFormat(e).into());
}
}
};
config.validate()?;
Ok(config)
}
async fn migrate_config(config_path: &PathBuf, config_content: &str) -> Result<Self, CliError> {
let mut existing: serde_yaml::Value =
serde_yaml::from_str(config_content).map_err(|e| ConfigError::InvalidFormat(e))?;
let default_config = Config::default();
let default_yaml: serde_yaml::Value =
serde_yaml::to_value(&default_config).map_err(|e| ConfigError::InvalidFormat(e))?;
let mut added_fields: Vec<String> = Vec::new();
if let (Some(existing_map), Some(default_map)) =
(existing.as_mapping_mut(), default_yaml.as_mapping())
{
for (key, default_value) in default_map {
if !existing_map.contains_key(key) {
if let Some(key_str) = key.as_str() {
added_fields.push(key_str.to_string());
}
existing_map.insert(key.clone(), default_value.clone());
}
}
}
let config: Config =
serde_yaml::from_value(existing.clone()).map_err(|e| ConfigError::InvalidFormat(e))?;
let updated_content = Self::generate_config_template(&config);
tokio::fs::write(config_path, &updated_content).await?;
if !added_fields.is_empty() {
eprintln!(
"Config migrated: added missing field(s): {}",
added_fields.join(", ")
);
eprintln!("Updated config saved to: {}", config_path.display());
}
Ok(config)
}
pub async fn save(&self) -> Result<(), CliError> {
let config_path = Self::get_config_path()?;
let config_content = Self::generate_config_template(self);
tokio::fs::write(&config_path, config_content).await?;
Ok(())
}
pub fn validate(&self) -> Result<(), CliError> {
Url::parse(&self.rpc_base_url).map_err(|e| ConfigError::InvalidUrl(e.to_string()))?;
for (name, network) in &self.networks {
Url::parse(&network.url)
.map_err(|e| ConfigError::InvalidUrl(format!("network '{}': {}", name, e)))?;
}
self.keys
.get_default_key()
.map_err(|e| ConfigError::InvalidPrivateKey(e.to_string()))?;
Pubkey::new(self.uploader_program_public_key.clone())
.map_err(|e| ConfigError::InvalidPublicKey(e.to_string()))?;
Pubkey::new(self.manager_program_public_key.clone())
.map_err(|e| ConfigError::InvalidPublicKey(e.to_string()))?;
Pubkey::new(self.abi_manager_program_public_key.clone())
.map_err(|e| ConfigError::InvalidPublicKey(e.to_string()))?;
Pubkey::new(self.token_program_public_key.clone())
.map_err(|e| ConfigError::InvalidPublicKey(e.to_string()))?;
Pubkey::new(self.consensus_validator_program_public_key.clone())
.map_err(|e| ConfigError::InvalidPublicKey(e.to_string()))?;
Pubkey::new(self.consensus_attestor_table_public_key.clone())
.map_err(|e| ConfigError::InvalidPublicKey(e.to_string()))?;
Pubkey::new(self.consensus_converted_vault_public_key.clone())
.map_err(|e| ConfigError::InvalidPublicKey(e.to_string()))?;
Pubkey::new(self.consensus_unclaimed_vault_public_key.clone())
.map_err(|e| ConfigError::InvalidPublicKey(e.to_string()))?;
Ok(())
}
pub fn get_consensus_validator_program_pubkey(&self) -> Result<Pubkey, CliError> {
Pubkey::new(self.consensus_validator_program_public_key.clone())
.map_err(|e| ConfigError::InvalidPublicKey(e.to_string()).into())
}
pub fn get_consensus_attestor_table_pubkey(&self) -> Result<Pubkey, CliError> {
Pubkey::new(self.consensus_attestor_table_public_key.clone())
.map_err(|e| ConfigError::InvalidPublicKey(e.to_string()).into())
}
pub fn get_consensus_converted_vault_pubkey(&self) -> Result<Pubkey, CliError> {
Pubkey::new(self.consensus_converted_vault_public_key.clone())
.map_err(|e| ConfigError::InvalidPublicKey(e.to_string()).into())
}
pub fn get_consensus_unclaimed_vault_pubkey(&self) -> Result<Pubkey, CliError> {
Pubkey::new(self.consensus_unclaimed_vault_public_key.clone())
.map_err(|e| ConfigError::InvalidPublicKey(e.to_string()).into())
}
pub fn get_wthru_program_pubkey(&self) -> Result<Pubkey, CliError> {
if self.wthru_program_public_key.trim().is_empty() {
return Err(CliError::Validation(
"wthru_program_public_key is not configured; set it in config.yaml or pass --program".to_string(),
));
}
Pubkey::new(self.wthru_program_public_key.clone())
.map_err(|e| ConfigError::InvalidPublicKey(e.to_string()).into())
}
pub fn get_config_path() -> Result<PathBuf, CliError> {
let home_dir = dirs::home_dir().ok_or_else(|| CliError::Generic {
message: "Could not find home directory".to_string(),
})?;
Ok(home_dir.join(".thru").join("cli").join("config.yaml"))
}
pub fn get_config_dir() -> Result<PathBuf, CliError> {
let home_dir = dirs::home_dir().ok_or_else(|| CliError::Generic {
message: "Could not find home directory".to_string(),
})?;
Ok(home_dir.join(".thru").join("cli"))
}
pub async fn create_default_config() -> Result<(), CliError> {
let config_dir = Self::get_config_dir()?;
let config_path = Self::get_config_path()?;
if !config_dir.exists() {
tokio::fs::create_dir_all(&config_dir)
.await
.map_err(ConfigError::DirectoryCreation)?;
}
let default_config = Config::default();
let config_content = Self::generate_config_template(&default_config);
tokio::fs::write(&config_path, config_content).await?;
println!(
"Created default configuration at: {}",
config_path.display()
);
println!("Please edit the configuration file to set your private key and RPC endpoint.");
Ok(())
}
fn generate_config_template(config: &Config) -> String {
let yaml_content = serde_yaml::to_string(config).unwrap_or_default();
format!(
r#"# Thru CLI Configuration File
# This file contains settings for the Thru command-line interface
# WARNING: Keep this file secure and never share your private keys
{}
"#,
yaml_content
)
}
pub fn get_grpc_url(&self) -> Result<Url, CliError> {
let mut url =
Url::parse(&self.rpc_base_url).map_err(|e| ConfigError::InvalidUrl(e.to_string()))?;
let scheme = url.scheme().to_string();
if scheme != "http" && scheme != "https" {
return Err(ConfigError::InvalidUrl(format!(
"unsupported scheme '{}'; expected http or https",
scheme
))
.into());
}
if url.host_str().is_none() {
return Err(
ConfigError::InvalidUrl("missing host in gRPC endpoint".to_string()).into(),
);
}
if url.path() != "/" && !url.path().is_empty() {
return Err(ConfigError::InvalidUrl(
"gRPC endpoint must not include a path".to_string(),
)
.into());
}
url.set_path("/");
if url.port().is_none() {
match scheme.as_str() {
"http" => {
let _ = url.set_port(Some(80));
}
"https" => {
let _ = url.set_port(Some(443));
}
_ => {}
}
}
Ok(url)
}
#[allow(dead_code)]
pub fn get_private_key_bytes(&self) -> Result<[u8; 32], CliError> {
let default_key = self.keys.get_default_key()?;
let bytes =
hex::decode(default_key).map_err(|e| ConfigError::InvalidPrivateKey(e.to_string()))?;
if bytes.len() != 32 {
return Err(ConfigError::InvalidPrivateKey(
"Private key must be exactly 32 bytes".to_string(),
)
.into());
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
Ok(key)
}
pub fn get_uploader_pubkey(&self) -> Result<Pubkey, CliError> {
Pubkey::new(self.uploader_program_public_key.clone())
.map_err(|e| ConfigError::InvalidPublicKey(e.to_string()).into())
}
pub fn get_manager_pubkey(&self) -> Result<Pubkey, CliError> {
Pubkey::new(self.manager_program_public_key.clone())
.map_err(|e| ConfigError::InvalidPublicKey(e.to_string()).into())
}
pub fn get_abi_manager_pubkey(&self) -> Result<Pubkey, CliError> {
Pubkey::new(self.abi_manager_program_public_key.clone())
.map_err(|e| ConfigError::InvalidPublicKey(e.to_string()).into())
}
pub fn get_token_program_pubkey(&self) -> Result<Pubkey, CliError> {
Pubkey::new(self.token_program_public_key.clone())
.map_err(|e| ConfigError::InvalidPublicKey(e.to_string()).into())
}
pub fn list_network_names(&self) -> Vec<String> {
let mut names: Vec<String> = self.networks.keys().cloned().collect();
names.sort();
names
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config_validation() {
let config = Config::default();
assert!(config.validate().is_ok());
}
#[test]
fn test_invalid_private_key() {
let mut config = Config::default();
assert!(config.keys.add_key("default", "invalid", true).is_err());
}
#[test]
fn test_invalid_url() {
let mut config = Config::default();
config.rpc_base_url = "not-a-url".to_string();
assert!(config.validate().is_err());
}
#[test]
fn test_grpc_url_http_default_port() {
let mut config = Config::default();
config.rpc_base_url = "http://localhost".to_string();
let grpc_url = config.get_grpc_url().unwrap();
assert_eq!(grpc_url.port_or_known_default(), Some(80));
}
#[test]
fn test_grpc_url_https_default_port() {
let mut config = Config::default();
config.rpc_base_url = "https://grpc.alphanet.thruput.org".to_string();
let grpc_url = config.get_grpc_url().unwrap();
assert_eq!(grpc_url.port_or_known_default(), Some(443));
}
#[test]
fn test_grpc_url_explicit_port_443() {
let mut config = Config::default();
config.rpc_base_url = "https://grpc.alphanet.thruput.org:443".to_string();
let grpc_url = config.get_grpc_url().unwrap();
assert_eq!(grpc_url.port_or_known_default(), Some(443));
}
#[test]
fn test_grpc_url_explicit_port_8443() {
let mut config = Config::default();
config.rpc_base_url = "https://grpc.alphanet.thruput.org:8443".to_string();
let grpc_url = config.get_grpc_url().unwrap();
assert_eq!(grpc_url.port(), Some(8443));
}
#[test]
fn test_grpc_url_explicit_port_8472() {
let mut config = Config::default();
config.rpc_base_url = "http://localhost:8472".to_string();
let grpc_url = config.get_grpc_url().unwrap();
assert_eq!(grpc_url.port(), Some(8472));
}
#[test]
fn test_grpc_url_explicit_port_8080() {
let mut config = Config::default();
config.rpc_base_url = "http://localhost:8080".to_string();
let grpc_url = config.get_grpc_url().unwrap();
assert_eq!(grpc_url.port(), Some(8080));
}
#[test]
fn test_grpc_url_explicit_port_9000() {
let mut config = Config::default();
config.rpc_base_url = "http://localhost:9000".to_string();
let grpc_url = config.get_grpc_url().unwrap();
assert_eq!(grpc_url.port(), Some(9000));
}
}