use crate::{
chain::quantus_subxt,
cli::address_format::QuantusSS58,
error::QuantusError,
log_error, log_print, log_success, log_verbose,
wallet::{
default_derivation_path,
password::{get_mnemonic_from_user, get_new_wallet_password, read_mnemonic_file},
DilithiumScheme, WalletManager,
},
};
use clap::Subcommand;
use colored::Colorize;
use sp_core::crypto::{AccountId32 as SpAccountId32, Ss58Codec};
use std::io::{self, Write};
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
#[derive(Subcommand, Debug)]
pub enum WalletCommands {
Create {
#[arg(short, long)]
name: String,
#[arg(short, long, hide = true)]
password: Option<String>,
#[arg(long)]
password_file: Option<String>,
#[arg(long)]
allow_empty_password: bool,
#[arg(short = 'd', long)]
derivation_path: Option<String>,
#[arg(long)]
no_derivation: bool,
#[arg(long, value_enum, default_value_t = DilithiumScheme::MlDsa65)]
scheme: DilithiumScheme,
},
View {
#[arg(short, long)]
name: Option<String>,
#[arg(short, long)]
all: bool,
},
Export {
#[arg(short, long)]
name: String,
#[arg(short, long, hide = true)]
password: Option<String>,
#[arg(short, long, default_value = "mnemonic")]
format: String,
#[arg(short, long)]
output: Option<std::path::PathBuf>,
},
Import {
#[arg(short, long)]
name: String,
#[arg(short, long, hide = true)]
password: Option<String>,
#[arg(long)]
password_file: Option<String>,
#[arg(long)]
allow_empty_password: bool,
#[arg(long)]
mnemonic_file: Option<String>,
#[arg(short = 'd', long)]
derivation_path: Option<String>,
#[arg(long)]
no_derivation: bool,
#[arg(long, value_enum, default_value_t = DilithiumScheme::MlDsa65)]
scheme: DilithiumScheme,
},
ImportCold {
#[arg(short, long)]
name: String,
#[arg(short, long)]
address: Option<String>,
#[arg(long, default_value_t = 0)]
camera_index: u32,
},
FromSeed {
#[arg(short, long)]
name: String,
#[arg(short, long, hide = true)]
password: Option<String>,
#[arg(long)]
password_file: Option<String>,
#[arg(long)]
allow_empty_password: bool,
#[arg(long, value_enum, default_value_t = DilithiumScheme::MlDsa65)]
scheme: DilithiumScheme,
},
List,
Delete {
#[arg(short, long)]
name: String,
#[arg(short, long)]
force: bool,
},
Nonce {
#[arg(short, long)]
address: Option<String>,
#[arg(short, long, required_unless_present("address"))]
wallet: Option<String>,
#[arg(short, long, hide = true)]
password: Option<String>,
},
}
pub async fn get_account_nonce(
quantus_client: &crate::chain::client::QuantusClient,
account_address: &str,
) -> crate::error::Result<u32> {
log_verbose!("#️⃣ Querying nonce for account: {}", account_address.bright_green());
let (account_id_sp, _) = SpAccountId32::from_ss58check_with_version(account_address)
.map_err(|e| QuantusError::NetworkError(format!("Invalid SS58 address: {e:?}")))?;
log_verbose!("🔍 SP Account ID: {:?}", account_id_sp);
let account_bytes: [u8; 32] = *account_id_sp.as_ref();
let account_id = subxt::ext::subxt_core::utils::AccountId32::from(account_bytes);
log_verbose!("🔍 SubXT Account ID: {:?}", account_id);
use quantus_subxt::api;
let storage_addr = api::storage().system().account(account_id);
let latest_block_hash = quantus_client.get_latest_block().await?;
let storage_at = quantus_client.client().storage().at(latest_block_hash);
let account_info = storage_at
.fetch(&storage_addr)
.await
.map_err(|e| QuantusError::NetworkError(format!("Failed to fetch account info: {e:?}")))?;
let (nonce, exists) = crate::chain::client::QuantusClient::interpret_account_nonce(
account_info.map(|info| info.nonce),
);
if exists {
log_verbose!("✅ Account info retrieved with storage query!");
} else {
log_print!(
"⚠️ Account has no on-chain System::Account entry; reporting nonce 0 (new/unused account)"
);
}
log_verbose!("🔢 Nonce: {} (exists={})", nonce, exists);
Ok(nonce)
}
async fn fetch_high_security_status(
quantus_client: &crate::chain::client::QuantusClient,
account_ss58: &str,
) -> crate::error::Result<Option<(String, String)>> {
use quantus_subxt::api::runtime_types::qp_scheduler::BlockNumberOrTimestamp;
let (account_id_sp, _) = SpAccountId32::from_ss58check_with_version(account_ss58)
.map_err(|e| QuantusError::Generic(format!("Invalid SS58 for HS lookup: {e:?}")))?;
let account_bytes: [u8; 32] = *account_id_sp.as_ref();
let account_id = subxt::ext::subxt_core::utils::AccountId32::from(account_bytes);
let storage_addr = quantus_subxt::api::storage()
.reversible_transfers()
.high_security_accounts(account_id);
let latest = quantus_client.get_latest_block().await?;
let value = quantus_client
.client()
.storage()
.at(latest)
.fetch(&storage_addr)
.await
.map_err(|e| QuantusError::NetworkError(format!("Fetch HS storage: {e:?}")))?;
let Some(data) = value else {
return Ok(None);
};
let guardian_ss58 = data.guardian.to_quantus_ss58();
let delay_str = match data.delay {
BlockNumberOrTimestamp::BlockNumber(blocks) => format!("{} blocks", blocks),
BlockNumberOrTimestamp::Timestamp(ms) => format!("{} seconds", ms / 1000),
};
Ok(Some((guardian_ss58, delay_str)))
}
pub(crate) async fn fetch_entrusted_accounts(
quantus_client: &crate::chain::client::QuantusClient,
guardian_ss58: &str,
) -> crate::error::Result<Vec<String>> {
let guardian_sp = SpAccountId32::from_ss58check(guardian_ss58)
.map_err(|e| QuantusError::Generic(format!("Invalid SS58 for guardian: {e:?}")))?;
let guardian_bytes: [u8; 32] = *guardian_sp.as_ref();
let guardian = subxt::ext::subxt_core::utils::AccountId32::from(guardian_bytes);
let latest = quantus_client.get_latest_block().await?;
let query = quantus_subxt::api::storage()
.reversible_transfers()
.high_security_accounts_iter();
let mut entries =
quantus_client.client().storage().at(latest).iter(query).await.map_err(|e| {
QuantusError::NetworkError(format!("Iter high_security_accounts: {e:?}"))
})?;
let mut list = Vec::new();
while let Some(entry) = entries.next().await {
let entry = entry.map_err(|e| {
QuantusError::NetworkError(format!("Read high_security_accounts: {e:?}"))
})?;
if entry.value.guardian != guardian {
continue;
}
let key = entry.key_bytes;
let account_bytes: [u8; 32] = key[key.len() - 32..].try_into().map_err(|_| {
QuantusError::Generic("high_security_accounts key shorter than an account".to_string())
})?;
list.push(
subxt::ext::subxt_core::utils::AccountId32::from(account_bytes).to_quantus_ss58(),
);
}
Ok(list)
}
async fn fetch_pending_transfers_for_guardian(
quantus_client: &crate::chain::client::QuantusClient,
entrusted_ss58: &[String],
) -> crate::error::Result<(u32, Vec<(String, u32)>)> {
let latest = quantus_client.get_latest_block().await?;
let storage = quantus_client.client().storage().at(latest);
let pending_iter =
quantus_subxt::api::storage().reversible_transfers().pending_transfers_iter();
let entrusted_ids: Vec<[u8; 32]> = entrusted_ss58
.iter()
.map(|s| {
let id = SpAccountId32::from_ss58check(s).map_err(|e| {
QuantusError::Generic(format!("Invalid SS58 for pending lookup: {e:?}"))
})?;
Ok::<_, crate::error::QuantusError>(*id.as_ref())
})
.collect::<Result<Vec<_>, _>>()?;
let mut counts: std::collections::HashMap<[u8; 32], u32> =
entrusted_ids.iter().map(|id| (*id, 0u32)).collect();
let mut iter = storage
.iter(pending_iter)
.await
.map_err(|e| QuantusError::NetworkError(format!("Pending transfers iter: {e:?}")))?;
while let Some(result) = iter.next().await {
if let Ok(entry) = result {
let from_bytes: [u8; 32] = *entry.value.from.as_ref();
if let Some(c) = counts.get_mut(&from_bytes) {
*c += 1;
}
}
}
let mut total = 0u32;
let mut per_account = Vec::with_capacity(entrusted_ss58.len());
for (ss58, id) in entrusted_ss58.iter().zip(entrusted_ids.iter()) {
let count = *counts.get(id).unwrap_or(&0);
total += count;
per_account.push((ss58.clone(), count));
}
Ok((total, per_account))
}
fn write_mnemonic_to_protected_file(
path: &std::path::Path,
mnemonic: &str,
) -> crate::error::Result<()> {
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
options.mode(0o600);
let mut file = options.open(path).map_err(|e| {
QuantusError::Generic(format!("Failed to create mnemonic export file: {e}"))
})?;
file.write_all(mnemonic.as_bytes())
.map_err(|e| QuantusError::Generic(format!("Failed to write mnemonic export file: {e}")))?;
file.write_all(b"\n")
.map_err(|e| QuantusError::Generic(format!("Failed to write mnemonic export file: {e}")))?;
file.sync_all()
.map_err(|e| QuantusError::Generic(format!("Failed to sync mnemonic export file: {e}")))?;
Ok(())
}
fn checkphrase_line(address: &str) -> Option<String> {
(!address.starts_with('['))
.then(|| crate::wallet::checkphrase::checkphrase(address).bright_blue().to_string())
}
pub async fn handle_wallet_command(
command: WalletCommands,
node_url: &str,
) -> crate::error::Result<()> {
match command {
WalletCommands::Create {
name,
password,
password_file,
allow_empty_password,
derivation_path,
no_derivation,
scheme,
} => {
log_print!("🔐 Creating new quantum wallet...");
let final_password =
get_new_wallet_password(&name, password, password_file, allow_empty_password)?;
let wallet_manager = WalletManager::new()?;
let result = if no_derivation {
wallet_manager
.create_wallet_no_derivation_with_scheme(&name, Some(&final_password), scheme)
.await
} else {
let path =
derivation_path.as_deref().unwrap_or_else(|| default_derivation_path(scheme));
wallet_manager
.create_wallet_with_scheme(&name, Some(&final_password), path, scheme)
.await
};
match result {
Ok(wallet_info) => {
log_success!("Wallet name: {}", name.bright_green());
log_success!("Address: {}", wallet_info.address.bright_cyan());
if let Some(checkphrase) = checkphrase_line(&wallet_info.address) {
log_success!("Checkphrase: {}", checkphrase);
}
log_success!("Key type: {}", wallet_info.key_type.bright_yellow());
log_success!(
"Derivation path: {}",
wallet_info.derivation_path.bright_magenta()
);
log_success!(
"Created: {}",
wallet_info.created_at.format("%Y-%m-%d %H:%M:%S UTC").to_string().dimmed()
);
log_success!("✅ Wallet created successfully!");
},
Err(e) => {
log_error!("{}", format!("❌ Failed to create wallet: {e}").red());
return Err(e);
},
}
Ok(())
},
WalletCommands::ImportCold { name, address, camera_index } => {
let wallet_manager = WalletManager::new()?;
let address = match address {
Some(address) => address,
None => {
log_print!("📷 Point the camera at the address QR shown on your cold wallet");
let address = crate::qr::scan_quantus_address(camera_index).await?;
log_print!("📇 Scanned address: {}", address.bright_cyan());
print!("Import this address as '{name}'? [Enter to confirm / q to abort]: ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
if input.trim().eq_ignore_ascii_case("q") {
return Err(QuantusError::Generic("Import aborted".to_string()));
}
address
},
};
match wallet_manager.create_cold_wallet(&name, &address) {
Ok(wallet_info) => {
log_success!("Wallet name: {}", name.bright_green());
log_success!("Address: {}", wallet_info.address.bright_cyan());
if let Some(checkphrase) = checkphrase_line(&wallet_info.address) {
log_success!("Checkphrase: {}", checkphrase);
}
log_success!("Key type: {}", wallet_info.key_type.bright_yellow());
log_success!("✅ Cold wallet imported (watch-only)");
log_print!(
"ℹ️ Use `{name}` with any extrinsic command (`--from {name}`): the CLI shows a QR for the cold wallet and scans its signed response."
);
Ok(())
},
Err(e) => {
log_error!("{}", format!("❌ Failed to import cold wallet: {e}").red());
Err(e)
},
}
},
WalletCommands::View { name, all } => {
log_print!("👁️ Viewing wallet information...");
let wallet_manager = WalletManager::new()?;
if all {
match wallet_manager.list_wallets() {
Ok(wallets) =>
if wallets.is_empty() {
log_print!("{}", "No wallets found.".dimmed());
} else {
log_print!("All wallets ({}):\n", wallets.len());
for (i, wallet) in wallets.iter().enumerate() {
log_print!(
"{}. {}",
(i + 1).to_string().bright_yellow(),
wallet.name.bright_green()
);
log_print!(" Address: {}", wallet.address.bright_cyan());
if let Some(checkphrase) = checkphrase_line(&wallet.address) {
log_print!(" Checkphrase: {}", checkphrase);
}
log_print!(" Type: {}", wallet.key_type.bright_yellow());
log_print!(
" Derivation Path: {}",
wallet.derivation_path.bright_magenta()
);
log_print!(
" Created: {}",
wallet
.created_at
.format("%Y-%m-%d %H:%M:%S UTC")
.to_string()
.dimmed()
);
if i < wallets.len() - 1 {
log_print!();
}
}
},
Err(e) => {
log_error!("{}", format!("❌ Failed to view wallets: {e}").red());
return Err(e);
},
}
} else if let Some(wallet_name) = name {
match wallet_manager.get_wallet(&wallet_name, None) {
Ok(Some(wallet_info)) => {
log_print!("Wallet Details:\n");
log_print!("Name: {}", wallet_info.name.bright_green());
log_print!("Address: {}", wallet_info.address.bright_cyan());
if let Some(checkphrase) = checkphrase_line(&wallet_info.address) {
log_print!("Checkphrase: {}", checkphrase);
}
log_print!("Key Type: {}", wallet_info.key_type.bright_yellow());
log_print!(
"Derivation Path: {}",
wallet_info.derivation_path.bright_magenta()
);
log_print!(
"Created: {}",
wallet_info
.created_at
.format("%Y-%m-%d %H:%M:%S UTC")
.to_string()
.dimmed()
);
if wallet_info.address.contains("[") {
log_print!(
"\n{}",
"💡 To see the full address, use the export command with password"
.dimmed()
);
}
if !wallet_info.address.contains("[") {
if let Ok(quantus_client) =
crate::chain::client::QuantusClient::new(node_url).await
{
match fetch_high_security_status(
&quantus_client,
&wallet_info.address,
)
.await
{
Ok(Some((interceptor_ss58, delay_str))) => {
log_print!(
"\n🛡️ High Security: {}",
"ENABLED".bright_green().bold()
);
log_print!(
" Guardian/Interceptor: {}",
interceptor_ss58.bright_cyan()
);
log_print!(" Delay: {}", delay_str.bright_yellow());
},
Ok(None) => {
log_print!("\n🛡️ High Security: {}", "DISABLED".dimmed());
},
Err(e) => {
log_verbose!("High Security status skipped: {}", e);
log_print!(
"\n{}",
"💡 Run quantus high-security status --account <address> to check on-chain"
.dimmed()
);
},
}
if let Ok(entrusted) =
fetch_entrusted_accounts(&quantus_client, &wallet_info.address)
.await
{
if entrusted.is_empty() {
log_print!("🛡️ Guardian for: {}", "none".dimmed());
} else {
log_print!(
"\n🛡️ Guardian for: {} account(s)",
entrusted.len().to_string().bright_green()
);
for (i, addr) in entrusted.iter().enumerate() {
log_print!(" {}. {}", i + 1, addr.bright_cyan());
}
if let Ok((total, per_account)) =
fetch_pending_transfers_for_guardian(
&quantus_client,
&entrusted,
)
.await
{
if total > 0 {
log_print!(
"\n {} {} pending transfer(s) you can intercept",
"⚠️".bright_yellow(),
total.to_string().bright_yellow().bold()
);
for (addr, count) in per_account {
if count > 0 {
log_print!(
" from {}: {}",
addr.bright_cyan(),
count
);
}
}
log_print!(" {}", "Use: quantus reversible cancel --tx-id <id> --from <you>".dimmed());
}
}
}
}
} else {
log_verbose!(
"Could not connect to node; High Security status skipped."
);
}
}
},
Ok(None) => {
log_error!("{}", format!("❌ Wallet '{wallet_name}' not found").red());
log_print!(
"Use {} to see available wallets",
"quantus wallet list".bright_green()
);
},
Err(e) => {
log_error!("{}", format!("❌ Failed to view wallet: {e}").red());
return Err(e);
},
}
} else {
log_print!(
"{}",
"Please specify a wallet name with --name or use --all to show all wallets"
.yellow()
);
log_print!("Examples:");
log_print!(" {}", "quantus wallet view --name my-wallet".bright_green());
log_print!(" {}", "quantus wallet view --all".bright_green());
}
Ok(())
},
WalletCommands::Export { name, password, format, output } => {
log_print!("📤 Exporting wallet...");
if format.to_lowercase() != "mnemonic" {
log_error!("Only 'mnemonic' export format is currently supported.");
return Err(crate::error::QuantusError::Generic(
"Export format not supported".to_string(),
));
}
let Some(output_path) = output else {
log_error!(
"Refusing to print the mnemonic to stdout. Use --output <file> to create a protected export file."
);
return Err(crate::error::QuantusError::Generic(
"Mnemonic export requires --output".to_string(),
));
};
let wallet_manager = WalletManager::new()?;
match wallet_manager.export_mnemonic(&name, password.as_deref()) {
Ok(mnemonic) => {
write_mnemonic_to_protected_file(&output_path, &mnemonic)?;
log_success!("✅ Wallet exported successfully!");
log_print!(
"Mnemonic written to: {}",
output_path.display().to_string().bright_cyan()
);
log_print!(
"{}",
"⚠️ Keep this file safe and secret. Anyone with this phrase can access your funds."
.bright_red()
);
},
Err(e) => {
log_error!("{}", format!("❌ Failed to export wallet: {e}").red());
return Err(e);
},
}
Ok(())
},
WalletCommands::Import {
name,
password,
password_file,
allow_empty_password,
mnemonic_file,
derivation_path,
no_derivation,
scheme,
} => {
log_print!("📥 Importing wallet...");
let wallet_manager = WalletManager::new()?;
let mut file_mnemonic = mnemonic_file.as_deref().map(read_mnemonic_file).transpose()?;
let final_password = crate::wallet::password::get_new_wallet_password(
&name,
password,
password_file,
allow_empty_password,
)
.inspect_err(|_| {
if let Some(m) = file_mnemonic.as_mut() {
crate::wallet::keystore::zeroize_string(m);
}
})?;
let mut mnemonic_phrase = match file_mnemonic {
Some(mnemonic) => mnemonic,
None => get_mnemonic_from_user()?,
};
let result = if no_derivation {
wallet_manager
.import_wallet_no_derivation_with_scheme(
&name,
&mnemonic_phrase,
Some(&final_password),
scheme,
)
.await
} else {
let path =
derivation_path.as_deref().unwrap_or_else(|| default_derivation_path(scheme));
wallet_manager
.import_wallet_with_scheme(
&name,
&mnemonic_phrase,
Some(&final_password),
path,
scheme,
)
.await
};
crate::wallet::keystore::zeroize_string(&mut mnemonic_phrase);
match result {
Ok(wallet_info) => {
log_success!("Wallet name: {}", name.bright_green());
log_success!("Address: {}", wallet_info.address.bright_cyan());
if let Some(checkphrase) = checkphrase_line(&wallet_info.address) {
log_success!("Checkphrase: {}", checkphrase);
}
log_success!("Key type: {}", wallet_info.key_type.bright_yellow());
log_success!(
"Derivation path: {}",
wallet_info.derivation_path.bright_magenta()
);
log_success!(
"Imported: {}",
wallet_info.created_at.format("%Y-%m-%d %H:%M:%S UTC").to_string().dimmed()
);
log_success!("✅ Wallet imported successfully!");
},
Err(e) => {
log_error!("{}", format!("❌ Failed to import wallet: {e}").red());
return Err(e);
},
}
Ok(())
},
WalletCommands::FromSeed {
name,
password,
password_file,
allow_empty_password,
scheme,
} => {
log_print!("🌱 Creating wallet from seed...");
let wallet_manager = WalletManager::new()?;
let final_password = crate::wallet::password::get_new_wallet_password(
&name,
password,
password_file,
allow_empty_password,
)?;
log_print!("Enter 32-byte seed in hex format (64 hex characters):");
let mut seed_raw = rpassword::read_password()
.map_err(|e| QuantusError::Generic(format!("Failed to read seed: {e}")))?;
let mut seed = seed_raw.trim().to_string();
crate::wallet::keystore::zeroize_string(&mut seed_raw);
let result = wallet_manager
.create_wallet_from_seed_with_scheme(&name, &seed, Some(&final_password), scheme)
.await;
crate::wallet::keystore::zeroize_string(&mut seed);
match result {
Ok(wallet_info) => {
log_success!("Wallet name: {}", name.bright_green());
log_success!("Address: {}", wallet_info.address.bright_cyan());
if let Some(checkphrase) = checkphrase_line(&wallet_info.address) {
log_success!("Checkphrase: {}", checkphrase);
}
log_success!("Key type: {}", wallet_info.key_type.bright_yellow());
log_success!(
"Created: {}",
wallet_info.created_at.format("%Y-%m-%d %H:%M:%S UTC").to_string().dimmed()
);
log_success!("✅ Wallet created from seed successfully!");
},
Err(e) => {
log_error!("{}", format!("❌ Failed to create wallet from seed: {e}").red());
return Err(e);
},
}
Ok(())
},
WalletCommands::List => {
log_print!("📋 Listing all wallets...");
let wallet_manager = WalletManager::new()?;
match wallet_manager.list_wallets() {
Ok(wallets) =>
if wallets.is_empty() {
log_print!("{}", "No wallets found.".dimmed());
log_print!(
"Create a new wallet with: {}",
"quantus wallet create --name <name>".bright_green()
);
} else {
log_print!("Found {} wallet(s):\n", wallets.len());
for (i, wallet) in wallets.iter().enumerate() {
log_print!(
"{}. {}",
(i + 1).to_string().bright_yellow(),
wallet.name.bright_green()
);
log_print!(" Address: {}", wallet.address.bright_cyan());
if let Some(checkphrase) = checkphrase_line(&wallet.address) {
log_print!(" Checkphrase: {}", checkphrase);
}
log_print!(" Type: {}", wallet.key_type.bright_yellow());
log_print!(
" Created: {}",
wallet
.created_at
.format("%Y-%m-%d %H:%M:%S UTC")
.to_string()
.dimmed()
);
if i < wallets.len() - 1 {
log_print!();
}
}
log_print!(
"\n{}",
"💡 Use 'quantus wallet view --name <wallet>' to see full details"
.dimmed()
);
},
Err(e) => {
log_error!("{}", format!("❌ Failed to list wallets: {e}").red());
return Err(e);
},
}
Ok(())
},
WalletCommands::Delete { name, force } => {
log_print!("🗑️ Deleting wallet...");
let wallet_manager = WalletManager::new()?;
let wallet_info = match wallet_manager.get_wallet(&name, None) {
Ok(Some(wallet_info)) => Some(wallet_info),
Ok(None) => {
log_error!("{}", format!("❌ Wallet '{name}' not found").red());
log_print!(
"Use {} to see available wallets",
"quantus wallet list".bright_green()
);
return Ok(());
},
Err(e) => {
log_print!(
"{}",
format!("⚠️ Wallet file for '{name}' exists but cannot be parsed: {e}")
.yellow()
);
log_print!(" Deleting will remove the corrupt wallet file.");
None
},
};
if let Some(wallet_info) = wallet_info {
log_print!("Wallet to delete:");
log_print!(" Name: {}", wallet_info.name.bright_green());
log_print!(" Address: {}", wallet_info.address.bright_cyan());
if let Some(checkphrase) = checkphrase_line(&wallet_info.address) {
log_print!(" Checkphrase: {}", checkphrase);
}
log_print!(" Type: {}", wallet_info.key_type.bright_yellow());
log_print!(
" Created: {}",
wallet_info.created_at.format("%Y-%m-%d %H:%M:%S UTC").to_string().dimmed()
);
}
if !force {
log_print!("\n{}", "⚠️ This action cannot be undone!".bright_red());
log_print!("Type the wallet name to confirm deletion:");
print!("Confirm wallet name: ");
io::stdout().flush().unwrap();
let mut input = String::new();
io::stdin().read_line(&mut input).unwrap();
let input = input.trim();
if input != name {
log_print!("{}", "❌ Wallet name doesn't match. Deletion cancelled.".red());
return Ok(());
}
}
match wallet_manager.delete_wallet(&name) {
Ok(true) => {
log_success!("✅ Wallet '{}' deleted successfully!", name);
},
Ok(false) => {
log_error!("{}", format!("❌ Wallet '{name}' was not found").red());
},
Err(e) => {
log_error!("{}", format!("❌ Failed to delete wallet: {e}").red());
return Err(e);
},
}
Ok(())
},
WalletCommands::Nonce { address, wallet, password } => {
log_print!("🔢 Querying account nonce...");
let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?;
let target_address = match (address, wallet) {
(Some(addr), _) => {
SpAccountId32::from_ss58check(&addr)
.map_err(|e| QuantusError::Generic(format!("Invalid address: {e:?}")))?;
addr
},
(None, Some(wallet_name)) =>
crate::wallet::load_signer_from_wallet(&wallet_name, password, None)?
.try_account_id_ss58check()?,
(None, None) => {
unreachable!("Either --address or --wallet must be provided");
},
};
log_print!("Account: {}", target_address.bright_cyan());
match get_account_nonce(&quantus_client, &target_address).await {
Ok(nonce) => {
log_success!("Nonce: {}", nonce.to_string().bright_green());
},
Err(e) => {
log_print!("❌ Failed to get nonce: {}", e);
return Err(e);
},
}
Ok(())
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
use serial_test::serial;
use tempfile::TempDir;
#[derive(Parser, Debug)]
#[command(name = "quantus")]
struct TestCli {
#[command(subcommand)]
command: crate::cli::Commands,
}
fn temp_home() -> TempDir {
let home = TempDir::new().expect("temp HOME");
std::env::set_var("HOME", home.path());
std::env::set_var("QUANTUS_NO_UPDATE_CHECK", "1");
std::env::remove_var("QUANTUS_WALLET_PASSWORD");
home
}
#[tokio::test]
#[serial]
async fn wallet_export_without_output_refuses_stdout_mnemonic() {
let _home = temp_home();
let manager = WalletManager::new().expect("wallet manager");
manager.create_wallet("export-leak", Some("")).await.expect("create wallet");
let result = handle_wallet_command(
WalletCommands::Export {
name: "export-leak".to_string(),
password: None,
format: "mnemonic".to_string(),
output: None,
},
"ws://127.0.0.1:9944",
)
.await;
assert!(result.is_err(), "export without --output must refuse stdout mnemonic emission");
assert!(
result.unwrap_err().to_string().contains("requires --output"),
"error should mention --output"
);
}
#[tokio::test]
#[serial]
async fn wallet_export_writes_mnemonic_to_protected_file_not_stdout_path() {
let home = temp_home();
std::env::remove_var("QUANTUS_WALLET_PASSWORD_EXPORT_FILE");
let manager = WalletManager::new().expect("wallet manager");
manager.create_wallet("export-file", Some("")).await.expect("create wallet");
let mnemonic = manager
.export_mnemonic("export-file", None)
.expect("export mnemonic for fixture");
let out = home.path().join("mnemonic.txt");
handle_wallet_command(
WalletCommands::Export {
name: "export-file".to_string(),
password: None,
format: "mnemonic".to_string(),
output: Some(out.clone()),
},
"ws://127.0.0.1:9944",
)
.await
.expect("export with --output must succeed");
let written = std::fs::read_to_string(&out).expect("export file");
assert_eq!(written.trim(), mnemonic.trim());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&out).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "export file must be owner-read/write only");
}
}
#[test]
fn wallet_import_rejects_mnemonic_cli_argument() {
let result = TestCli::try_parse_from([
"quantus",
"wallet",
"import",
"--name",
"poc",
"--mnemonic",
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art",
]);
assert!(result.is_err(), "wallet import must not accept --mnemonic on the command line");
}
#[test]
fn wallet_import_accepts_mnemonic_file_flag() {
let parsed = TestCli::try_parse_from([
"quantus",
"wallet",
"import",
"--name",
"poc",
"--mnemonic-file",
"mnemonic.txt",
"--allow-empty-password",
])
.expect("wallet import must accept --mnemonic-file");
match parsed.command {
crate::cli::Commands::Wallet(WalletCommands::Import {
mnemonic_file, name, ..
}) => {
assert_eq!(name, "poc");
assert_eq!(mnemonic_file.as_deref(), Some("mnemonic.txt"));
},
other => panic!("expected Wallet(Import), got {other:?}"),
}
}
#[tokio::test]
#[serial]
async fn wallet_import_from_mnemonic_file_matches_source_address() {
let home = temp_home();
std::env::remove_var("QUANTUS_WALLET_PASSWORD_SRC");
std::env::remove_var("QUANTUS_WALLET_PASSWORD_IMPORTED");
let manager = WalletManager::new().expect("wallet manager");
let source = manager.create_wallet("src", Some("")).await.expect("create source wallet");
let mnemonic = manager.export_mnemonic("src", None).expect("export mnemonic");
let mnemonic_path = home.path().join("mnemonic.txt");
write_mnemonic_to_protected_file(&mnemonic_path, &mnemonic).expect("write mnemonic file");
handle_wallet_command(
WalletCommands::Import {
name: "imported".to_string(),
password: None,
password_file: None,
allow_empty_password: true,
mnemonic_file: Some(mnemonic_path.to_string_lossy().into_owned()),
derivation_path: None,
no_derivation: false,
scheme: DilithiumScheme::MlDsa65,
},
"ws://127.0.0.1:9944",
)
.await
.expect("import from mnemonic file");
let imported = manager.load_wallet("imported", "").expect("load imported");
assert_eq!(
imported.keypair.try_to_account_id_ss58check().expect("imported address"),
source.address
);
assert_eq!(imported.mnemonic.as_deref(), Some(mnemonic.trim()));
}
#[test]
fn wallet_from_seed_rejects_seed_cli_argument() {
let result = TestCli::try_parse_from([
"quantus",
"wallet",
"from-seed",
"--name",
"poc",
"--seed",
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
]);
assert!(result.is_err(), "wallet from-seed must not accept --seed on the command line");
}
#[test]
fn wallet_scheme_cli_uses_hyphen_before_security_level() {
let parsed = TestCli::try_parse_from([
"quantus",
"wallet",
"create",
"--name",
"poc",
"--scheme",
"ml-dsa-87",
"--allow-empty-password",
])
.expect("ml-dsa-87 must parse");
match parsed.command {
crate::cli::Commands::Wallet(WalletCommands::Create { scheme, .. }) => {
assert_eq!(scheme, DilithiumScheme::MlDsa87)
},
other => panic!("expected Wallet(Create), got {other:?}"),
}
let legacy = TestCli::try_parse_from([
"quantus",
"wallet",
"create",
"--name",
"poc",
"--scheme",
"ml-dsa87",
"--allow-empty-password",
]);
assert!(legacy.is_err(), "unhyphenated ml-dsa87 must not parse");
}
}