//! aldabra — MCP server entry point. //! //! Speaks MCP over stdio. Any MCP client (e.g. Claude Code) //! launches this as a subprocess and gets a wallet's worth of tools. //! //! Logging: stderr only — stdout is the MCP transport and must stay //! clean. mod bootstrap; mod config; mod tools; use std::process::ExitCode; use anyhow::Result; use rmcp::{transport::stdio, ServiceExt}; use tracing_subscriber::EnvFilter; use crate::config::Config; use crate::tools::WalletService; #[tokio::main] async fn main() -> ExitCode { tracing_subscriber::fmt() .with_writer(std::io::stderr) .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) .init(); match run().await { Ok(()) => ExitCode::SUCCESS, Err(e) => { tracing::error!("{e:#}"); ExitCode::FAILURE } } } async fn run() -> Result<()> { let cfg = Config::load()?; tracing::info!( network = ?cfg.network, koios = %cfg.koios_base, koios_bearer_set = cfg.koios_bearer.is_some(), account = cfg.account, index = cfg.index, data_dir = %cfg.data_dir.display(), safe_reads_root = %cfg.safe_reads_root.display(), "aldabra starting" ); // Make sure the sandbox root exists and is daemon-only readable. // Tools canonicalize() against this dir to validate `*_path` // args, which requires the dir to be a real directory on disk. // Chmod is `?`'d not swallowed — if the filesystem refuses it // (noexec, selinux, broken mount), fail loudly instead of // silently falling back to umask 0o755. if !cfg.safe_reads_root.exists() { std::fs::create_dir_all(&cfg.safe_reads_root).map_err(|e| { anyhow::anyhow!( "create safe_reads_root {}: {e}", cfg.safe_reads_root.display() ) })?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&cfg.safe_reads_root, std::fs::Permissions::from_mode(0o700)) .map_err(|e| { anyhow::anyhow!( "chmod 0o700 safe_reads_root {}: {e}", cfg.safe_reads_root.display() ) })?; } } // CLI mode flags — all out-of-band, before the MCP transport // takes over stdio. // // `--generate-mnemonic` — print a fresh phrase, exit. No disk write. // `--bootstrap` — paste an existing phrase, encrypt, derive. // `--bootstrap-new` — generate, display, encrypt, derive (one shot). // `--bootstrap-from-xprv` — paste a root_xsk1... bech32 (cnode root.prv, // cardano-address output), encrypt, derive. // Power-user import path for keys that came // from outside the BIP-39 mnemonic flow. // (none) — load existing, start MCP server. let args: Vec = std::env::args().collect(); let generate_only = args.iter().any(|a| a == "--generate-mnemonic"); let bootstrap_only = args.iter().any(|a| a == "--bootstrap"); let bootstrap_new = args.iter().any(|a| a == "--bootstrap-new"); let bootstrap_from_xprv = args.iter().any(|a| a == "--bootstrap-from-xprv"); if generate_only { bootstrap::print_fresh_mnemonic()?; return Ok(()); } let mnemonic_path = bootstrap::mnemonic_path(&cfg.data_dir); let xprv_path = bootstrap::root_xprv_path(&cfg.data_dir); let any_key_exists = mnemonic_path.exists() || xprv_path.exists(); // Scope `root` to a block so its XPrv drops + wipes as soon as // we've extracted the keys we need. let (payment_key, stake_key, address) = { let root = if bootstrap_new { bootstrap::generate_and_save_root_key(&cfg.data_dir)? } else if bootstrap_from_xprv { bootstrap::import_root_xprv(&cfg.data_dir)? } else if any_key_exists || bootstrap_only { // Loads existing (or runs the mnemonic-paste flow on // first-run with --bootstrap). load_or_create_root_key // itself picks between mnemonic.age and root-xprv.age. bootstrap::load_or_create_root_key(&cfg.data_dir)? } else { anyhow::bail!( "no key at {}. run one of:\n \ `aldabra --bootstrap` (paste existing 24-word phrase)\n \ `aldabra --bootstrap-new` (generate a fresh wallet)\n \ `aldabra --bootstrap-from-xprv` (paste a root_xsk1... bech32)", cfg.data_dir.display() ); }; let address = aldabra_core::derive_base_address(&root, cfg.network, cfg.account, cfg.index)?; let payment_key = aldabra_core::derive_payment_key(&root, cfg.account, cfg.index); let stake_key = aldabra_core::derive_stake_key(&root, cfg.account); (payment_key, stake_key, address) // root drops here — XPrv::Drop wipes the 96 bytes }; tracing::info!(%address, "derived base address"); if bootstrap_only || bootstrap_new { eprintln!("aldabra: bootstrap complete. address = {address}"); return Ok(()); } // Hand off to the MCP server. From this point on stdin/stdout // belong to the JSON-RPC transport — no more eprintln-prompting. let service = WalletService::new( cfg.network, address, cfg.koios_base, cfg.koios_bearer, payment_key, stake_key, cfg.max_send_lovelace, cfg.data_dir.clone(), cfg.safe_reads_root.clone(), ); let server = service .serve(stdio()) .await .map_err(|e| anyhow::anyhow!("rmcp serve failed: {e}"))?; server .waiting() .await .map_err(|e| anyhow::anyhow!("rmcp wait failed: {e}"))?; Ok(()) }