solvela_client_cli_args/
lib.rs1use std::path::PathBuf;
8
9use clap::Args;
10
11use solvela_client::Wallet;
12
13#[derive(Debug, Clone, Args)]
15pub struct WalletArgs {
16 #[arg(long, default_value = "SOLVELA_WALLET_KEY")]
18 pub wallet_env: String,
19
20 #[arg(long, default_value = "~/.solvela/wallet.json")]
22 pub wallet_file: String,
23}
24
25#[derive(Debug, Clone, Args)]
27pub struct GatewayArgs {
28 #[arg(short = 'g', long, default_value = "https://api.solvela.ai")]
30 pub gateway: String,
31}
32
33#[derive(Debug, Clone, Args)]
35pub struct RpcArgs {
36 #[arg(long, default_value = "https://api.mainnet-beta.solana.com")]
38 pub rpc_url: String,
39}
40
41#[must_use]
45pub fn expand_home(path: &str) -> PathBuf {
46 if let Some(rest) = path.strip_prefix("~/") {
47 match dirs_next::home_dir() {
48 Some(home) => home.join(rest),
49 None => PathBuf::from(path),
50 }
51 } else {
52 PathBuf::from(path)
53 }
54}
55
56pub fn load_wallet(args: &WalletArgs) -> Result<Wallet, String> {
65 if let Ok(val) = std::env::var(&args.wallet_env) {
67 if !val.is_empty() {
68 return Wallet::from_keypair_b58(&val)
69 .map_err(|e| format!("invalid keypair in {}: {e}", args.wallet_env));
70 }
71 }
72
73 let expanded = expand_home(&args.wallet_file);
75
76 if expanded.exists() {
78 #[cfg(unix)]
80 {
81 use std::os::unix::fs::MetadataExt;
82 if let Ok(meta) = expanded.metadata() {
83 if meta.mode() & 0o077 != 0 {
84 tracing::warn!(
85 path = %expanded.display(),
86 "wallet file has insecure permissions (should be 0600)"
87 );
88 }
89 }
90 }
91
92 let contents = std::fs::read_to_string(&expanded)
93 .map_err(|e| format!("failed to read {}: {e}", expanded.display()))?;
94
95 let bytes: Vec<u8> = serde_json::from_str(&contents)
97 .map_err(|e| format!("invalid wallet file format in {}: {e}", expanded.display()))?;
98
99 return Wallet::from_keypair_bytes(&bytes)
100 .map_err(|e| format!("invalid keypair in {}: {e}", expanded.display()));
101 }
102
103 Err(format!(
104 "no wallet found: set {} env var or create {}",
105 args.wallet_env,
106 expanded.display()
107 ))
108}
109
110pub fn save_wallet(path: &str, keypair_bytes: &[u8], force: bool) -> Result<PathBuf, String> {
120 let expanded = expand_home(path);
121
122 if expanded.exists() && !force {
123 return Err(format!(
124 "wallet file already exists at {} (use --force to overwrite)",
125 expanded.display()
126 ));
127 }
128
129 if let Some(parent) = expanded.parent() {
131 std::fs::create_dir_all(parent)
132 .map_err(|e| format!("failed to create directory {}: {e}", parent.display()))?;
133 }
134
135 let json = serde_json::to_string(&keypair_bytes)
137 .map_err(|e| format!("failed to serialize keypair: {e}"))?;
138
139 #[cfg(unix)]
142 {
143 use std::io::Write;
144 use std::os::unix::fs::OpenOptionsExt;
145 let mut file = std::fs::OpenOptions::new()
146 .write(true)
147 .create(true)
148 .truncate(true)
149 .mode(0o600)
150 .open(&expanded)
151 .map_err(|e| format!("failed to create {}: {e}", expanded.display()))?;
152 file.write_all(json.as_bytes())
153 .map_err(|e| format!("failed to write {}: {e}", expanded.display()))?;
154 }
155
156 #[cfg(not(unix))]
157 {
158 std::fs::write(&expanded, &json)
168 .map_err(|e| format!("failed to write {}: {e}", expanded.display()))?;
169 tracing::warn!(
170 path = %expanded.display(),
171 "wallet file created without owner-only ACL on non-Unix platform; \
172 treat the file location as sensitive (MEDIUM-3)"
173 );
174 }
175
176 Ok(expanded)
177}
178
179#[cfg(test)]
180mod tests {
181 use solana_sdk::signer::Signer;
182
183 use super::*;
184
185 #[test]
186 fn test_expand_home_with_tilde() {
187 let result = expand_home("~/some/path");
188 assert!(
190 !result.to_string_lossy().starts_with('~'),
191 "path was not expanded: {result:?}"
192 );
193 assert!(
194 result.to_string_lossy().ends_with("some/path"),
195 "path suffix missing: {result:?}"
196 );
197 }
198
199 #[test]
200 fn test_expand_home_without_tilde() {
201 let result = expand_home("/absolute/path");
202 assert_eq!(result, PathBuf::from("/absolute/path"));
203
204 let relative = expand_home("relative/path");
205 assert_eq!(relative, PathBuf::from("relative/path"));
206 }
207
208 #[test]
209 fn test_load_wallet_from_env() {
210 let kp = solana_sdk::signer::keypair::Keypair::new();
212 let b58 = bs58::encode(kp.to_bytes()).into_string();
213 let expected_addr = kp.pubkey().to_string();
214
215 let env_var = "SOLVELA_TEST_WALLET_LOAD_ENV_7291";
217 std::env::set_var(env_var, &b58);
218
219 let args = WalletArgs {
220 wallet_env: env_var.to_string(),
221 wallet_file: "/nonexistent/path.json".to_string(),
222 };
223
224 let wallet = load_wallet(&args).expect("should load from env");
225 assert_eq!(wallet.address(), expected_addr);
226
227 std::env::remove_var(env_var);
229 }
230
231 #[test]
232 fn test_load_wallet_no_source() {
233 let env_var = "SOLVELA_TEST_WALLET_NOSOURCE_4821";
235 std::env::remove_var(env_var);
236
237 let args = WalletArgs {
238 wallet_env: env_var.to_string(),
239 wallet_file: "/nonexistent/wallet_file_that_does_not_exist.json".to_string(),
240 };
241
242 let result = load_wallet(&args);
243 assert!(result.is_err());
244 let err = result.unwrap_err();
245 assert!(
246 err.contains("no wallet found"),
247 "unexpected error message: {err}"
248 );
249 }
250
251 #[test]
252 fn test_save_wallet_creates_file() {
253 let dir = std::env::temp_dir().join("solvela_test_save_wallet");
254 let _ = std::fs::remove_dir_all(&dir);
255 std::fs::create_dir_all(&dir).unwrap();
256
257 let kp = solana_sdk::signer::keypair::Keypair::new();
258 let bytes = kp.to_bytes();
259 let file_path = dir.join("test_wallet.json");
260 let path_str = file_path.to_string_lossy().to_string();
261
262 let result = save_wallet(&path_str, &bytes, false);
263 assert!(result.is_ok(), "save_wallet failed: {result:?}");
264
265 let saved_path = result.unwrap();
266 assert!(saved_path.exists());
267
268 let contents = std::fs::read_to_string(&saved_path).unwrap();
270 let parsed: Vec<u8> = serde_json::from_str(&contents).unwrap();
271 assert_eq!(parsed.len(), 64);
272 assert_eq!(&parsed[..], &bytes[..]);
273
274 #[cfg(unix)]
276 {
277 use std::os::unix::fs::MetadataExt;
278 let meta = saved_path.metadata().unwrap();
279 assert_eq!(meta.mode() & 0o777, 0o600, "permissions should be 0600");
280 }
281
282 let _ = std::fs::remove_dir_all(&dir);
284 }
285
286 #[test]
287 fn test_save_wallet_refuses_overwrite() {
288 let dir = std::env::temp_dir().join("solvela_test_save_overwrite");
289 let _ = std::fs::remove_dir_all(&dir);
290 std::fs::create_dir_all(&dir).unwrap();
291
292 let kp = solana_sdk::signer::keypair::Keypair::new();
293 let bytes = kp.to_bytes();
294 let file_path = dir.join("existing_wallet.json");
295 let path_str = file_path.to_string_lossy().to_string();
296
297 save_wallet(&path_str, &bytes, false).unwrap();
299
300 let result = save_wallet(&path_str, &bytes, false);
302 assert!(result.is_err());
303 let err = result.unwrap_err();
304 assert!(err.contains("already exists"), "unexpected error: {err}");
305
306 let result = save_wallet(&path_str, &bytes, true);
308 assert!(result.is_ok(), "force overwrite failed: {result:?}");
309
310 let _ = std::fs::remove_dir_all(&dir);
312 }
313}