use std::path::PathBuf;
use clap::Args;
use solvela_client::Wallet;
#[derive(Debug, Clone, Args)]
pub struct WalletArgs {
#[arg(long, default_value = "SOLVELA_WALLET_KEY")]
pub wallet_env: String,
#[arg(long, default_value = "~/.solvela/wallet.json")]
pub wallet_file: String,
}
#[derive(Debug, Clone, Args)]
pub struct GatewayArgs {
#[arg(short = 'g', long, default_value = "https://api.solvela.ai")]
pub gateway: String,
}
#[derive(Debug, Clone, Args)]
pub struct RpcArgs {
#[arg(long, default_value = "https://api.mainnet-beta.solana.com")]
pub rpc_url: String,
}
#[must_use]
pub fn expand_home(path: &str) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/") {
match dirs_next::home_dir() {
Some(home) => home.join(rest),
None => PathBuf::from(path),
}
} else {
PathBuf::from(path)
}
}
pub fn load_wallet(args: &WalletArgs) -> Result<Wallet, String> {
if let Ok(val) = std::env::var(&args.wallet_env) {
if !val.is_empty() {
return Wallet::from_keypair_b58(&val)
.map_err(|e| format!("invalid keypair in {}: {e}", args.wallet_env));
}
}
let expanded = expand_home(&args.wallet_file);
if expanded.exists() {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if let Ok(meta) = expanded.metadata() {
if meta.mode() & 0o077 != 0 {
tracing::warn!(
path = %expanded.display(),
"wallet file has insecure permissions (should be 0600)"
);
}
}
}
let contents = std::fs::read_to_string(&expanded)
.map_err(|e| format!("failed to read {}: {e}", expanded.display()))?;
let bytes: Vec<u8> = serde_json::from_str(&contents)
.map_err(|e| format!("invalid wallet file format in {}: {e}", expanded.display()))?;
return Wallet::from_keypair_bytes(&bytes)
.map_err(|e| format!("invalid keypair in {}: {e}", expanded.display()));
}
Err(format!(
"no wallet found: set {} env var or create {}",
args.wallet_env,
expanded.display()
))
}
pub fn save_wallet(path: &str, keypair_bytes: &[u8], force: bool) -> Result<PathBuf, String> {
let expanded = expand_home(path);
if expanded.exists() && !force {
return Err(format!(
"wallet file already exists at {} (use --force to overwrite)",
expanded.display()
));
}
if let Some(parent) = expanded.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("failed to create directory {}: {e}", parent.display()))?;
}
let json = serde_json::to_string(&keypair_bytes)
.map_err(|e| format!("failed to serialize keypair: {e}"))?;
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&expanded)
.map_err(|e| format!("failed to create {}: {e}", expanded.display()))?;
file.write_all(json.as_bytes())
.map_err(|e| format!("failed to write {}: {e}", expanded.display()))?;
}
#[cfg(not(unix))]
{
std::fs::write(&expanded, &json)
.map_err(|e| format!("failed to write {}: {e}", expanded.display()))?;
tracing::warn!(
path = %expanded.display(),
"wallet file created without owner-only ACL on non-Unix platform; \
treat the file location as sensitive (MEDIUM-3)"
);
}
Ok(expanded)
}
#[cfg(test)]
mod tests {
use solana_sdk::signer::Signer;
use super::*;
#[test]
fn test_expand_home_with_tilde() {
let result = expand_home("~/some/path");
assert!(
!result.to_string_lossy().starts_with('~'),
"path was not expanded: {result:?}"
);
assert!(
result.to_string_lossy().ends_with("some/path"),
"path suffix missing: {result:?}"
);
}
#[test]
fn test_expand_home_without_tilde() {
let result = expand_home("/absolute/path");
assert_eq!(result, PathBuf::from("/absolute/path"));
let relative = expand_home("relative/path");
assert_eq!(relative, PathBuf::from("relative/path"));
}
#[test]
fn test_load_wallet_from_env() {
let kp = solana_sdk::signer::keypair::Keypair::new();
let b58 = bs58::encode(kp.to_bytes()).into_string();
let expected_addr = kp.pubkey().to_string();
let env_var = "SOLVELA_TEST_WALLET_LOAD_ENV_7291";
std::env::set_var(env_var, &b58);
let args = WalletArgs {
wallet_env: env_var.to_string(),
wallet_file: "/nonexistent/path.json".to_string(),
};
let wallet = load_wallet(&args).expect("should load from env");
assert_eq!(wallet.address(), expected_addr);
std::env::remove_var(env_var);
}
#[test]
fn test_load_wallet_no_source() {
let env_var = "SOLVELA_TEST_WALLET_NOSOURCE_4821";
std::env::remove_var(env_var);
let args = WalletArgs {
wallet_env: env_var.to_string(),
wallet_file: "/nonexistent/wallet_file_that_does_not_exist.json".to_string(),
};
let result = load_wallet(&args);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.contains("no wallet found"),
"unexpected error message: {err}"
);
}
#[test]
fn test_save_wallet_creates_file() {
let dir = std::env::temp_dir().join("solvela_test_save_wallet");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let kp = solana_sdk::signer::keypair::Keypair::new();
let bytes = kp.to_bytes();
let file_path = dir.join("test_wallet.json");
let path_str = file_path.to_string_lossy().to_string();
let result = save_wallet(&path_str, &bytes, false);
assert!(result.is_ok(), "save_wallet failed: {result:?}");
let saved_path = result.unwrap();
assert!(saved_path.exists());
let contents = std::fs::read_to_string(&saved_path).unwrap();
let parsed: Vec<u8> = serde_json::from_str(&contents).unwrap();
assert_eq!(parsed.len(), 64);
assert_eq!(&parsed[..], &bytes[..]);
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let meta = saved_path.metadata().unwrap();
assert_eq!(meta.mode() & 0o777, 0o600, "permissions should be 0600");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_save_wallet_refuses_overwrite() {
let dir = std::env::temp_dir().join("solvela_test_save_overwrite");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let kp = solana_sdk::signer::keypair::Keypair::new();
let bytes = kp.to_bytes();
let file_path = dir.join("existing_wallet.json");
let path_str = file_path.to_string_lossy().to_string();
save_wallet(&path_str, &bytes, false).unwrap();
let result = save_wallet(&path_str, &bytes, false);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("already exists"), "unexpected error: {err}");
let result = save_wallet(&path_str, &bytes, true);
assert!(result.is_ok(), "force overwrite failed: {result:?}");
let _ = std::fs::remove_dir_all(&dir);
}
}