pub use crate::config::*;
pub use crate::lock::*;
pub use crate::paths::*;
use is_terminal::IsTerminal;
use rpassword::read_password;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::Command as ShellCommand;
use zeroize::Zeroizing;
pub use zinc_core::{
decrypt_wallet_internal, encrypt_wallet_internal, generate_wallet_internal,
validate_mnemonic_internal, Inscription, WalletBuilder, ZincMnemonic, ZincWallet,
};
use crate::error::AppError;
pub struct WalletSession {
pub wallet: ZincWallet,
pub profile: Profile,
pub profile_path: PathBuf,
}
impl WalletSession {
pub fn require_seed_mode(&self) -> Result<(), AppError> {
if self.profile.mode != ProfileModeArg::Seed {
return Err(AppError::Capability(
"This command requires a 'Seed' profile and cannot be performed in 'Watch' mode."
.to_string(),
));
}
Ok(())
}
}
#[must_use]
pub fn map_wallet_error(message: String) -> AppError {
let lower = message.to_ascii_lowercase();
if lower.contains("wrong password") || lower.contains("decryption failed") {
return AppError::Auth(message);
}
if lower.contains("capability missing")
|| lower.contains("read-only")
|| lower.contains("requires a seed-mode profile")
{
return AppError::Capability(message);
}
if lower.contains("insufficient") || lower.contains("not enough") {
return AppError::InsufficientFunds(message);
}
if lower.contains("security violation")
|| lower.contains("safety lock")
|| lower.contains("ordinal shield")
{
return AppError::Policy(message);
}
if lower.contains("http")
|| lower.contains("network")
|| lower.contains("esplora")
|| lower.contains("request")
{
return AppError::Network(message);
}
if lower.contains("not found") {
return AppError::NotFound(message);
}
AppError::Internal(message)
}
#[must_use]
pub fn read_lock_metadata(path: &Path) -> Option<LockMetadata> {
let data = fs::read_to_string(path).ok()?;
serde_json::from_str::<LockMetadata>(&data).ok()
}
pub fn wallet_password(config: &ServiceConfig<'_>) -> Result<Zeroizing<String>, AppError> {
if let Some(pass) = config.password_override {
return Ok(Zeroizing::new(pass.to_string()));
}
if config.password_stdin {
let mut buffer = String::new();
io::stdin()
.read_line(&mut buffer)
.map_err(|e| AppError::Auth(format!("failed to read password from stdin: {e}")))?;
let pass = strip_line_endings(buffer);
if pass.is_empty() {
return Err(AppError::Auth("password from stdin is empty".to_string()));
}
return Ok(Zeroizing::new(pass));
}
if let Some(pass) = std::env::var_os(config.password_env) {
let pass = pass.to_string_lossy().to_string();
if pass.is_empty() {
return Err(AppError::Auth(format!(
"environment variable {} is empty",
config.password_env
)));
}
return Ok(Zeroizing::new(pass));
}
if config.agent {
return Err(AppError::Auth(format!(
"wallet password missing (use --password-env or set {}, or --password-stdin)",
config.password_env
)));
}
if io::stdin().is_terminal() {
eprint!("Enter wallet password: ");
io::stderr().flush().ok();
let pass = match read_password() {
Ok(pass) => {
let normalized = strip_line_endings(pass);
if normalized.is_empty() {
eprintln!();
eprintln!(
"Hidden password input returned empty value; falling back to visible input."
);
read_visible_password()?
} else {
normalized
}
}
Err(hidden_err) => {
eprintln!();
eprintln!("Hidden password input unavailable; falling back to visible input.");
let mut buffer = String::new();
io::stdin().read_line(&mut buffer).map_err(|e| {
AppError::Auth(format!(
"failed to read password: {e} (hidden-input error: {hidden_err})"
))
})?;
strip_line_endings(buffer)
}
};
if pass.is_empty() {
return Err(AppError::Auth("password cannot be empty".to_string()));
}
return Ok(Zeroizing::new(pass));
}
Err(AppError::Auth(format!(
"wallet password missing (use --password-env, set {}, or run in interactive terminal)",
config.password_env
)))
}
fn strip_line_endings(value: String) -> String {
value.trim_end_matches(['\n', '\r']).to_string()
}
fn read_visible_password() -> Result<String, AppError> {
let mut buffer = String::new();
io::stdin()
.read_line(&mut buffer)
.map_err(|e| AppError::Auth(format!("failed to read password: {e}")))?;
Ok(strip_line_endings(buffer))
}
pub fn load_wallet_session(config: &ServiceConfig<'_>) -> Result<WalletSession, AppError> {
let path = profile_path(config)?;
let mut profile = read_profile(&path)?;
if apply_scan_policy_migration(&mut profile) {
write_profile(&path, &profile)?;
}
let persisted = load_persisted_config().unwrap_or_default();
let resolver = crate::config_resolver::ConfigResolver::new(&persisted, config);
let new_network: crate::config::NetworkArg =
resolver.resolve_network(Some(&profile)).value.into();
let new_scheme: crate::config::SchemeArg = resolver.resolve_scheme(Some(&profile)).value.into();
let new_payment_address_type: crate::config::PaymentAddressTypeArg = resolver
.resolve_payment_address_type(Some(&profile))
.value
.into();
let network_changed = profile.network.to_string() != new_network.to_string();
let scheme_changed = profile.scheme.to_string() != new_scheme.to_string();
let payment_type_changed =
profile.payment_address_type.to_string() != new_payment_address_type.to_string();
if network_changed || scheme_changed || payment_type_changed {
for state in profile.accounts.values_mut() {
state.persistence_json = None;
state.inscriptions_json = None;
}
}
if network_changed {
profile.esplora_url = crate::config::default_esplora_url(new_network).to_string();
profile.ord_url = crate::config::default_ord_url(new_network).to_string();
profile.pulse_url = crate::config::default_pulse_url(new_network).to_string();
}
profile.network = new_network;
profile.scheme = new_scheme;
profile.payment_address_type = new_payment_address_type;
if let Some(e) = config.esplora_url_override {
profile.esplora_url = e.to_string();
}
if let Some(url) = config.ord_url_override {
profile.ord_url = url.to_string();
}
if let Some(url) = config.pulse_url_override {
profile.pulse_url = url.to_string();
}
let state = profile.account_state();
let mut wallet = match profile.mode {
ProfileModeArg::Seed => {
let password = wallet_password(config)?;
let encrypted = profile.encrypted_mnemonic.as_ref().ok_or_else(|| {
AppError::Config("Seed profile missing encrypted mnemonic".to_string())
})?;
let wallet_phrase = decrypt_wallet_internal(encrypted, &password)
.map_err(|_| {
AppError::Auth(
"wallet decrypt failed (wrong password or corrupted vault)".to_string(),
)
})?
.phrase;
let mnemonic = ZincMnemonic::parse(&wallet_phrase)
.map_err(|e| AppError::Internal(format!("invalid mnemonic in vault: {e}")))?;
let mut builder = WalletBuilder::from_mnemonic(profile.network.into(), &mnemonic)
.with_scheme(profile.scheme.into())
.with_payment_address_type(profile.payment_address_type.into())
.with_account_index(profile.account_index)
.scan_policy(zinc_core::ScanPolicy {
account_gap_limit: profile.account_gap_limit,
address_scan_depth: profile.address_scan_depth,
});
if let Some(persistence_json) = &state.persistence_json {
builder = builder
.with_persistence(persistence_json)
.map_err(|e| AppError::Config(format!("failed to load persistence: {e}")))?;
}
builder
.build()
.map_err(|e| AppError::Internal(format!("wallet build failed: {e}")))?
}
ProfileModeArg::Watch => {
let taproot_xpub = profile.taproot_xpub.as_ref().ok_or_else(|| {
AppError::Config("Watch profile requires a taproot xpub".to_string())
})?;
let mut builder = WalletBuilder::from_watch_only(profile.network.into())
.with_scheme(profile.scheme.into())
.with_payment_address_type(profile.payment_address_type.into())
.with_account_index(profile.account_index)
.scan_policy(zinc_core::ScanPolicy {
account_gap_limit: profile.account_gap_limit,
address_scan_depth: profile.address_scan_depth,
});
builder = builder
.with_taproot_xpub(taproot_xpub)
.map_err(|e| AppError::Config(format!("invalid taproot xpub: {e}")))?;
if let Some(payment_xpub) = &profile.payment_xpub {
builder = builder
.with_payment_xpub(payment_xpub)
.map_err(|e| AppError::Config(format!("invalid payment xpub: {e}")))?;
}
if let Some(persistence_json) = &state.persistence_json {
builder = builder
.with_persistence(persistence_json)
.map_err(|e| AppError::Config(format!("failed to load persistence: {e}")))?;
}
builder
.build()
.map_err(|e| AppError::Internal(format!("wallet build failed: {e}")))?
}
ProfileModeArg::WatchAddress => {
let watch_address = profile.watch_address.as_ref().ok_or_else(|| {
AppError::Config("Watch-address profile requires an address".to_string())
})?;
let mut builder = WalletBuilder::from_watch_only(profile.network.into())
.with_scheme(profile.scheme.into())
.with_payment_address_type(profile.payment_address_type.into())
.with_account_index(profile.account_index)
.scan_policy(zinc_core::ScanPolicy {
account_gap_limit: profile.account_gap_limit,
address_scan_depth: profile.address_scan_depth,
})
.with_watch_address(watch_address)
.map_err(|e| AppError::Config(format!("invalid watch address: {e}")))?;
if let Some(persistence_json) = &state.persistence_json {
builder = builder
.with_persistence(persistence_json)
.map_err(|e| AppError::Config(format!("failed to load persistence: {e}")))?;
}
builder
.build()
.map_err(|e| AppError::Internal(format!("wallet build failed: {e}")))?
}
};
if let Some(inscriptions_json) = &state.inscriptions_json {
let inscriptions: Vec<Inscription> = serde_json::from_str(inscriptions_json)
.map_err(|e| AppError::Config(format!("invalid inscription cache: {e}")))?;
let protected_outpoints = inscriptions
.iter()
.map(|inscription| inscription.satpoint.outpoint)
.collect();
wallet.apply_verified_ordinals_update(inscriptions, protected_outpoints, Vec::new());
}
Ok(WalletSession {
wallet,
profile,
profile_path: path,
})
}
fn apply_scan_policy_migration(profile: &mut Profile) -> bool {
if profile.scan_policy_version >= SCAN_POLICY_VERSION_MAIN_ONLY {
return false;
}
for state in profile.accounts.values_mut() {
state.persistence_json = None;
state.inscriptions_json = None;
}
profile.scan_policy_version = SCAN_POLICY_VERSION_MAIN_ONLY;
profile.updated_at_unix = now_unix();
true
}
pub fn persist_wallet_session(session: &mut WalletSession) -> Result<(), AppError> {
let persistence = session
.wallet
.export_changeset()
.map_err(map_wallet_error)
.and_then(|persist| {
serde_json::to_string(&persist)
.map_err(|e| AppError::Internal(format!("persistence serialize failed: {e}")))
})?;
let inscriptions = serde_json::to_string(session.wallet.inscriptions())
.map_err(|e| AppError::Internal(format!("inscription cache serialize failed: {e}")))?;
session.profile.set_account_state(AccountState {
persistence_json: Some(persistence),
inscriptions_json: Some(inscriptions),
});
write_profile(&session.profile_path, &session.profile)
}
#[allow(dead_code)]
pub fn run_bitcoin_cli(profile: &Profile, args: &[String]) -> Result<String, AppError> {
let output = ShellCommand::new(&profile.bitcoin_cli)
.args(&profile.bitcoin_cli_args)
.args(args)
.output()
.map_err(|e| AppError::Config(format!("failed to launch {}: {e}", profile.bitcoin_cli)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let details = if stderr.is_empty() { stdout } else { stderr };
return Err(AppError::Network(format!(
"bitcoin-cli command failed: {} {}",
profile.bitcoin_cli, details
)));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{AccountState, NetworkArg, Profile, SchemeArg};
use std::collections::BTreeMap;
#[test]
fn scan_policy_migration_clears_cached_account_state_once() {
let mut accounts = BTreeMap::new();
accounts.insert(
0,
AccountState {
persistence_json: Some("{\"mock\":true}".to_string()),
inscriptions_json: Some("[{\"id\":\"i0\"}]".to_string()),
},
);
accounts.insert(
1,
AccountState {
persistence_json: Some("{\"mock\":true}".to_string()),
inscriptions_json: None,
},
);
let mut profile = Profile {
version: 1,
scan_policy_version: 0,
network: NetworkArg::Regtest,
scheme: SchemeArg::Dual,
payment_address_type: crate::config::PaymentAddressTypeArg::Native,
account_index: 0,
esplora_url: "https://esplora-rt.exittheloop.com".to_string(),
ord_url: "https://ord-rt.exittheloop.com".to_string(),
pulse_url: "http://localhost:8080".to_string(),
bitcoin_cli: "bitcoin-cli".to_string(),
bitcoin_cli_args: vec!["-regtest".to_string()],
encrypted_mnemonic: Some("encrypted".to_string()),
mode: crate::config::ProfileModeArg::Seed,
taproot_xpub: None,
payment_xpub: None,
watch_address: None,
account_gap_limit: crate::config::default_gap_limit(),
address_scan_depth: crate::config::default_scan_depth(),
accounts,
updated_at_unix: 123,
pulse_session: None,
};
assert!(apply_scan_policy_migration(&mut profile));
assert_eq!(profile.scan_policy_version, SCAN_POLICY_VERSION_MAIN_ONLY);
assert!(profile
.accounts
.values()
.all(|state| state.persistence_json.is_none() && state.inscriptions_json.is_none()));
let migrated_updated_at = profile.updated_at_unix;
assert!(!apply_scan_policy_migration(&mut profile));
assert_eq!(profile.scan_policy_version, SCAN_POLICY_VERSION_MAIN_ONLY);
assert_eq!(profile.updated_at_unix, migrated_updated_at);
}
#[test]
fn strip_line_endings_only_removes_trailing_newlines() {
assert_eq!(strip_line_endings("abc\n".to_string()), "abc");
assert_eq!(strip_line_endings("abc\r\n".to_string()), "abc");
assert_eq!(strip_line_endings(" abc \n".to_string()), " abc ");
assert_eq!(strip_line_endings("abc".to_string()), "abc");
}
}