phase 1: full read path — bip39 + cip-3 + cip-1852 + koios + age-mnemonic + rmcp

end-to-end working wallet: paste 24-word mnemonic, age-encrypt at rest,
on unlock derive root + payment + stake keys, build cip-19 base address,
serve four tools over mcp stdio (wallet.address, wallet.network,
wallet.balance, wallet.utxos).

deps added: ed25519-bip32 0.4 (pallas only ships raw ed25519, not the
cardano variant of bip32 hd derivation), cryptoxide 0.4 for pbkdf2-hmac-sha512,
age 0.10 for at-rest mnemonic encryption, rpassword 7 for tty-only passphrase
prompts, toml 0.9 for config.toml.

new modules:
- crates/aldabra-core/src/derive.rs — payment + stake key derivation, hash
- crates/aldabra-chain/src/koios.rs — real reqwest impl, asset aggregation
- crates/aldabra-mcp/src/{bootstrap,config,tools}.rs

caught one bug pre-flight: get_balance was clobbering same-asset
quantities across utxos instead of summing. fixed + regression test.

headless support via ALDABRA_PASSPHRASE env (mcp clients own stdin so
the rpassword prompt path can't run). docker secret / systemd
EnvironmentFile sources it in production.

dockerfile: multi-stage rust:1.95-bookworm → debian:bookworm-slim, tini
as pid1, non-root aldabra user, /var/lib/aldabra owned 700.

29 unit tests + 1 ignored live-koios test. preprod smoke test exercised
initialize → tools/list → tools/call wallet.address end-to-end via
piped json-rpc; correct preprod address came back from canonical
abandon-art mnemonic.

phase 2 (send) is next.
This commit is contained in:
Sulkta 2026-05-04 11:09:00 -07:00
parent edc976e5d9
commit 2b4fdff0c0
14 changed files with 4389 additions and 167 deletions

View file

@ -0,0 +1,211 @@
//! First-run mnemonic bootstrap + subsequent unlock.
//!
//! On startup, the daemon expects to find an age-encrypted mnemonic at
//! `$ALDABRA_DATA/mnemonic.age`. If it doesn't exist, we run a one-time
//! interactive bootstrap: prompt the user to paste a 24-word mnemonic,
//! prompt for an encryption passphrase, and write the encrypted file.
//! On subsequent runs we just prompt for the passphrase, decrypt, and
//! derive the root key.
//!
//! ## Memory hygiene
//!
//! - `Mnemonic` (in `aldabra-core`) zeroizes the entropy on drop.
//! - The decrypted phrase is held in a `Zeroizing<String>` for the
//! ~milliseconds between decrypt and `Mnemonic::from_phrase`.
//! - Passphrases pass through `age::secrecy::SecretString`, which
//! zeroizes its internal buffer on drop.
//!
//! Hygiene is best-effort. Live RAM in this process is the security
//! boundary; if the host is hostile, no amount of zeroize helps. The
//! threat model here is post-mortem dumps + swap leaks, not active
//! attackers.
use std::fs;
use std::io::{BufRead, Read, Write};
use std::path::Path;
use age::secrecy::SecretString;
use age::{Decryptor, Encryptor};
use aldabra_core::{Mnemonic, RootKey};
use anyhow::{anyhow, Context, Result};
use zeroize::Zeroizing;
const MNEMONIC_FILENAME: &str = "mnemonic.age";
/// Encrypt a mnemonic phrase with a passphrase. Pure — no I/O, no
/// prompts. Used by the interactive bootstrap and exposed for tests.
pub fn encrypt_mnemonic(phrase: &str, passphrase: &str) -> Result<Vec<u8>> {
let pp = SecretString::new(passphrase.to_string());
let encryptor = Encryptor::with_user_passphrase(pp);
let mut encrypted = Vec::with_capacity(phrase.len() + 256);
let mut writer = encryptor
.wrap_output(&mut encrypted)
.context("age: wrap_output")?;
writer.write_all(phrase.as_bytes())?;
writer.finish().context("age: finish writer")?;
Ok(encrypted)
}
/// Decrypt an age-encrypted mnemonic blob with a passphrase. Returns
/// the phrase wrapped in [`Zeroizing`] so it gets wiped from RAM when
/// dropped.
pub fn decrypt_mnemonic(blob: &[u8], passphrase: &str) -> Result<Zeroizing<String>> {
let pp = SecretString::new(passphrase.to_string());
let decryptor = match Decryptor::new(blob).context("age: parse header")? {
Decryptor::Passphrase(d) => d,
Decryptor::Recipients(_) => {
return Err(anyhow!(
"expected passphrase-encrypted age file, got recipients-encrypted"
))
}
};
let mut reader = decryptor
.decrypt(&pp, None)
.context("age: passphrase rejected or file corrupt")?;
let mut bytes = Zeroizing::new(Vec::with_capacity(256));
reader.read_to_end(&mut bytes)?;
let phrase = std::str::from_utf8(&bytes)
.context("decrypted mnemonic is not valid utf-8")?
.to_string();
Ok(Zeroizing::new(phrase))
}
/// Path of the encrypted mnemonic for a given data dir.
pub fn mnemonic_path(data_dir: &Path) -> std::path::PathBuf {
data_dir.join(MNEMONIC_FILENAME)
}
/// Interactive bootstrap. If `mnemonic.age` exists at `data_dir`,
/// prompt for the passphrase and unlock it. Otherwise, run the
/// first-run flow: prompt for phrase + passphrase, encrypt, write,
/// then derive.
///
/// Stderr-only output. stdout is reserved for the MCP transport.
pub fn load_or_create_root_key(data_dir: &Path) -> Result<RootKey> {
let path = mnemonic_path(data_dir);
if path.exists() {
eprintln!("aldabra: unlocking mnemonic at {}", path.display());
let blob = fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
// Headless-friendly: if ALDABRA_PASSPHRASE is set, use it
// and skip the prompt. Required when the daemon runs under
// an MCP client that owns stdin. Caller is responsible for
// sourcing the env from a secure place (systemd
// EnvironmentFile, docker secret, etc.).
let passphrase = match std::env::var("ALDABRA_PASSPHRASE") {
Ok(p) => p,
Err(_) => rpassword::prompt_password("passphrase: ")?,
};
let phrase = decrypt_mnemonic(&blob, &passphrase)?;
let mnemonic = Mnemonic::from_phrase(&phrase)?;
Ok(mnemonic.into_root_key()?)
} else {
eprintln!("aldabra: no mnemonic found at {}", path.display());
eprintln!("first-run bootstrap — this writes an encrypted mnemonic to disk.\n");
fs::create_dir_all(data_dir)
.with_context(|| format!("creating {}", data_dir.display()))?;
eprint!("paste 24-word BIP-39 mnemonic (visible) and press Enter: ");
std::io::stderr().flush().ok();
let mut phrase_buf = Zeroizing::new(String::new());
std::io::stdin()
.lock()
.read_line(&mut phrase_buf)
.context("reading mnemonic from stdin")?;
let trimmed: &str = phrase_buf.trim();
// Validate before asking for passphrase — fail fast on bad input.
Mnemonic::from_phrase(trimmed)?;
// Headless-friendly: if ALDABRA_PASSPHRASE is set, use it
// for the bootstrap passphrase too. No confirm loop in that
// case — the env var IS the source of truth.
let passphrase = match std::env::var("ALDABRA_PASSPHRASE") {
Ok(p) => p,
Err(_) => {
let p = rpassword::prompt_password("set encryption passphrase: ")?;
let confirm = rpassword::prompt_password("confirm passphrase: ")?;
if p != confirm {
return Err(anyhow!("passphrases did not match — re-run to retry"));
}
p
}
};
let blob = encrypt_mnemonic(trimmed, &passphrase)?;
fs::write(&path, &blob).with_context(|| format!("writing {}", path.display()))?;
restrict_to_owner(&path)?;
eprintln!("aldabra: mnemonic encrypted to {}", path.display());
let mnemonic = Mnemonic::from_phrase(trimmed)?;
Ok(mnemonic.into_root_key()?)
}
}
#[cfg(unix)]
fn restrict_to_owner(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)?.permissions();
perms.set_mode(0o600);
fs::set_permissions(path, perms).context("chmod 600")?;
Ok(())
}
#[cfg(not(unix))]
fn restrict_to_owner(_path: &Path) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const ABANDON_ART: &str = concat!(
"abandon abandon abandon abandon abandon abandon ",
"abandon abandon abandon abandon abandon abandon ",
"abandon abandon abandon abandon abandon abandon ",
"abandon abandon abandon abandon abandon art",
);
#[test]
fn encrypt_decrypt_round_trip() {
let blob = encrypt_mnemonic(ABANDON_ART, "hunter2").unwrap();
let decrypted = decrypt_mnemonic(&blob, "hunter2").unwrap();
assert_eq!(&*decrypted as &str, ABANDON_ART);
}
#[test]
fn wrong_passphrase_fails() {
let blob = encrypt_mnemonic(ABANDON_ART, "hunter2").unwrap();
let result = decrypt_mnemonic(&blob, "wrong");
assert!(result.is_err(), "decrypt with wrong passphrase should fail");
}
#[test]
fn ciphertext_differs_from_plaintext() {
let blob = encrypt_mnemonic(ABANDON_ART, "hunter2").unwrap();
// Sanity — make sure we're actually encrypting, not echoing.
let cipher_str = String::from_utf8_lossy(&blob);
assert!(!cipher_str.contains("abandon"));
}
#[test]
fn random_passphrases_produce_different_ciphertexts() {
// Same plaintext + different passphrases must not produce the
// same ciphertext (no deterministic-key reuse).
let a = encrypt_mnemonic(ABANDON_ART, "alpha").unwrap();
let b = encrypt_mnemonic(ABANDON_ART, "beta").unwrap();
assert_ne!(a, b);
}
#[test]
fn same_passphrase_produces_different_ciphertexts() {
// age uses random salt — repeat encryption with the SAME
// passphrase must still produce different ciphertexts. This
// is a salt sanity check.
let a = encrypt_mnemonic(ABANDON_ART, "hunter2").unwrap();
let b = encrypt_mnemonic(ABANDON_ART, "hunter2").unwrap();
assert_ne!(a, b);
}
}

View file

@ -0,0 +1,194 @@
//! Daemon configuration.
//!
//! Sources, in priority order:
//! 1. Environment variables (`ALDABRA_NETWORK`, `ALDABRA_KOIOS_BASE`,
//! `ALDABRA_ACCOUNT`, `ALDABRA_INDEX`, `ALDABRA_DATA`)
//! 2. `$ALDABRA_DATA/config.toml` if it exists
//! 3. Hardcoded defaults — preprod, public Koios, account 0, index 0,
//! data dir `~/.aldabra` (or `/var/lib/aldabra` if running as root)
//!
//! Env wins so a docker compose override doesn't require a config file
//! mount.
use std::path::PathBuf;
use aldabra_core::Network;
use serde::Deserialize;
use thiserror::Error;
#[derive(Debug, Clone)]
pub struct Config {
pub network: Network,
pub koios_base: String,
pub account: u32,
pub index: u32,
pub data_dir: PathBuf,
}
#[derive(Debug, Default, Deserialize)]
struct FileConfig {
#[serde(default)]
network: Option<String>,
#[serde(default)]
koios_base: Option<String>,
#[serde(default)]
account: Option<u32>,
#[serde(default)]
index: Option<u32>,
}
fn parse_network(s: &str) -> Result<Network, ConfigError> {
match s.to_ascii_lowercase().as_str() {
"mainnet" => Ok(Network::Mainnet),
"preview" => Ok(Network::Preview),
"preprod" => Ok(Network::Preprod),
other => Err(ConfigError::InvalidNetwork(other.to_string())),
}
}
fn default_koios_for(network: Network) -> &'static str {
match network {
Network::Mainnet => "https://api.koios.rest/api/v1",
Network::Preview => "https://preview.koios.rest/api/v1",
Network::Preprod => "https://preprod.koios.rest/api/v1",
}
}
fn default_data_dir() -> PathBuf {
if let Ok(home) = std::env::var("HOME") {
PathBuf::from(home).join(".aldabra")
} else {
PathBuf::from("/var/lib/aldabra")
}
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("invalid network {0:?}: expected mainnet|preview|preprod")]
InvalidNetwork(String),
#[error("config file at {path}: {source}")]
File {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("config file at {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("env var {var} not parseable: {value:?}")]
EnvParse { var: &'static str, value: String },
}
impl Config {
/// Resolve the effective config from env + file + defaults.
pub fn load() -> Result<Self, ConfigError> {
let data_dir: PathBuf = std::env::var("ALDABRA_DATA")
.map(PathBuf::from)
.unwrap_or_else(|_| default_data_dir());
// Optional file at $ALDABRA_DATA/config.toml
let file_path = data_dir.join("config.toml");
let file_cfg = if file_path.exists() {
let raw = std::fs::read_to_string(&file_path).map_err(|e| ConfigError::File {
path: file_path.clone(),
source: e,
})?;
toml::from_str::<FileConfig>(&raw).map_err(|e| ConfigError::Parse {
path: file_path.clone(),
source: e,
})?
} else {
FileConfig::default()
};
let network = if let Ok(env) = std::env::var("ALDABRA_NETWORK") {
parse_network(&env)?
} else if let Some(s) = file_cfg.network.as_deref() {
parse_network(s)?
} else {
Network::Preprod
};
let koios_base = std::env::var("ALDABRA_KOIOS_BASE")
.ok()
.or(file_cfg.koios_base)
.unwrap_or_else(|| default_koios_for(network).to_string());
let account = match std::env::var("ALDABRA_ACCOUNT") {
Ok(s) => s
.parse::<u32>()
.map_err(|_| ConfigError::EnvParse { var: "ALDABRA_ACCOUNT", value: s })?,
Err(_) => file_cfg.account.unwrap_or(0),
};
let index = match std::env::var("ALDABRA_INDEX") {
Ok(s) => s
.parse::<u32>()
.map_err(|_| ConfigError::EnvParse { var: "ALDABRA_INDEX", value: s })?,
Err(_) => file_cfg.index.unwrap_or(0),
};
Ok(Self {
network,
koios_base,
account,
index,
data_dir,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_full_toml() {
let toml = r#"
network = "mainnet"
koios_base = "https://my.koios/api/v1"
account = 7
index = 3
"#;
let cfg: FileConfig = toml::from_str(toml).unwrap();
assert_eq!(cfg.network.as_deref(), Some("mainnet"));
assert_eq!(cfg.koios_base.as_deref(), Some("https://my.koios/api/v1"));
assert_eq!(cfg.account, Some(7));
assert_eq!(cfg.index, Some(3));
}
#[test]
fn empty_toml_is_ok() {
let cfg: FileConfig = toml::from_str("").unwrap();
assert!(cfg.network.is_none());
assert!(cfg.koios_base.is_none());
}
#[test]
fn parse_network_accepts_canonical_names() {
assert!(matches!(parse_network("mainnet").unwrap(), Network::Mainnet));
assert!(matches!(parse_network("Preview").unwrap(), Network::Preview));
assert!(matches!(parse_network("PREPROD").unwrap(), Network::Preprod));
}
#[test]
fn parse_network_rejects_garbage() {
assert!(matches!(
parse_network("ghostnet"),
Err(ConfigError::InvalidNetwork(_))
));
}
#[test]
fn default_koios_per_network() {
assert!(default_koios_for(Network::Mainnet).contains("api.koios.rest"));
assert!(default_koios_for(Network::Preprod).contains("preprod.koios.rest"));
assert!(default_koios_for(Network::Preview).contains("preview.koios.rest"));
}
}

View file

@ -3,72 +3,99 @@
//! Speaks MCP over stdio. Any MCP client (e.g. Claude Code)
//! launches this as a subprocess and gets a wallet's worth of tools.
//!
//! ## Phase 1 tools
//! ## Phase 1 tools (target — server wiring lands in 1.7)
//!
//! - `wallet.address` — return the derived base address (placeholder
//! until aldabra-core's CIP-1852 derivation lands)
//! - `wallet.balance` — query balance via the configured chain backend
//! - `wallet.address` — derived CIP-1852 base address
//! - `wallet.balance` — ADA + native-asset balance via chain backend
//! - `wallet.utxos` — list UTXOs at the wallet address
//! - `wallet.network` — configured network selector
//!
//! ## Phase 2-4 tools (TODO)
//! ## Phase 2-4 tools
//!
//! See ROADMAP.md at the repo root.
//!
//! ## Config
//!
//! For now: hardcoded mainnet + a stub Koios client. Real config
//! loading + mnemonic-from-encrypted-file lands once the core
//! derivation API is real.
//! See `ROADMAP.md` at the repo root.
//!
//! ## Logging
//!
//! Stderr only — stdout is the MCP transport, 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() -> Result<()> {
// Stderr only — stdout is MCP transport
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();
tracing::info!("aldabra starting (phase 1 scaffold)");
match run().await {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
tracing::error!("{e:#}");
ExitCode::FAILURE
}
}
}
// TODO(phase 1):
// 1. Load config (network, koios url, mnemonic path)
// 2. Bootstrap mnemonic (interactive on first run, age-decrypt thereafter)
// 3. Derive root key
// 4. Build the chain backend
// 5. Construct the MCP server with tool handlers
// 6. Run it on stdio
// For now: a smoke-test print so the binary actually does something
// when invoked manually (not through MCP).
async fn run() -> Result<()> {
let cfg = Config::load()?;
tracing::info!(
target_address = %aldabra_core::derive_base_address(
&dummy_root_key()?,
aldabra_core::Network::Mainnet,
0,
0,
)?,
"scaffold smoke test — derived placeholder address",
network = ?cfg.network,
koios = %cfg.koios_base,
account = cfg.account,
index = cfg.index,
data_dir = %cfg.data_dir.display(),
"aldabra starting"
);
// First-run bootstrap reads the mnemonic from stdin, which would
// collide with the MCP transport once `serve()` runs. So
// bootstrap is gated behind a `--bootstrap` arg: do that once
// out-of-band, then start the daemon normally.
let bootstrap_only = std::env::args().any(|a| a == "--bootstrap");
let mnemonic_path = bootstrap::mnemonic_path(&cfg.data_dir);
if !mnemonic_path.exists() && !bootstrap_only {
anyhow::bail!(
"no mnemonic at {}. run `aldabra --bootstrap` first to set one up.",
mnemonic_path.display()
);
}
let root = bootstrap::load_or_create_root_key(&cfg.data_dir)?;
let address = aldabra_core::derive_base_address(
&root,
cfg.network,
cfg.account,
cfg.index,
)?;
tracing::info!(%address, "derived base address");
if bootstrap_only {
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);
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(())
}
/// Phase 1 only — produces a zero-bytes RootKey so the placeholder
/// address derivation runs. Will be deleted once real mnemonic loading
/// lands.
fn dummy_root_key() -> Result<aldabra_core::RootKey> {
// Need a way to construct one from this crate without exposing
// private fields. Phase 1: temporary public constructor on
// aldabra-core, gated behind a #[cfg(test)] or feature flag and
// removed before phase 2.
//
// For tonight: this fn is a TODO marker — the smoke test won't
// actually run until we finish aldabra-core::Mnemonic::into_root_key.
anyhow::bail!("phase 1 scaffold: real mnemonic loading not yet implemented")
}

View file

@ -0,0 +1,119 @@
//! MCP tool handlers — Phase 1 read-path tools.
//!
//! Each `#[tool]` becomes a discoverable MCP tool. Tool names use
//! dotted notation per the MCP convention; the underlying Rust fn
//! names use snake_case.
//!
//! Returns:
//! - `String` results pass through `IntoContents` directly.
//! - `Result<String, String>` lets us surface chain errors as MCP
//! tool-call errors instead of crashing the daemon.
use std::sync::Arc;
use aldabra_chain::{ChainBackend, KoiosClient};
use aldabra_core::Network;
use rmcp::{model::ServerInfo, tool, ServerHandler};
#[derive(Clone)]
pub struct WalletService {
inner: Arc<WalletInner>,
}
struct WalletInner {
network: Network,
address: String,
chain: KoiosClient,
}
impl WalletService {
pub fn new(network: Network, address: String, koios_base: String) -> Self {
Self {
inner: Arc::new(WalletInner {
network,
address,
chain: KoiosClient::new(koios_base),
}),
}
}
}
#[tool(tool_box)]
impl WalletService {
#[tool(
name = "wallet.address",
description = "Return the wallet's primary base address (CIP-1852, account 0, index 0) as a bech32 string"
)]
async fn wallet_address(&self) -> String {
self.inner.address.clone()
}
#[tool(
name = "wallet.network",
description = "Return the configured Cardano network: mainnet, preview, or preprod"
)]
async fn wallet_network(&self) -> String {
match self.inner.network {
Network::Mainnet => "mainnet".into(),
Network::Preview => "preview".into(),
Network::Preprod => "preprod".into(),
}
}
#[tool(
name = "wallet.balance",
description = "Query ADA + native-asset balance at the wallet address. Returns JSON {lovelace, assets}."
)]
async fn wallet_balance(&self) -> Result<String, String> {
let bal = self
.inner
.chain
.get_balance(&self.inner.address)
.await
.map_err(|e| e.to_string())?;
serde_json::to_string(&bal).map_err(|e| e.to_string())
}
#[tool(
name = "wallet.utxos",
description = "List UTXOs at the wallet address as a JSON array of {tx_hash, output_index, lovelace, assets}."
)]
async fn wallet_utxos(&self) -> Result<String, String> {
let utxos = self
.inner
.chain
.get_utxos(&self.inner.address)
.await
.map_err(|e| e.to_string())?;
serde_json::to_string(&utxos).map_err(|e| e.to_string())
}
}
#[tool(tool_box)]
impl ServerHandler for WalletService {
fn get_info(&self) -> ServerInfo {
ServerInfo {
instructions: Some(
"aldabra — Cardano lite wallet over MCP. Phase 1 (read path): wallet.address, wallet.network, wallet.balance, wallet.utxos. Spending tools land in phase 2.".into(),
),
..Default::default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn service_constructs_and_clones() {
let svc = WalletService::new(
Network::Preprod,
"addr_test1qxyz".into(),
"https://preprod.koios.rest/api/v1".into(),
);
let cloned = svc.clone();
// Arc<WalletInner> means clone is cheap and shares state.
assert_eq!(svc.inner.address, cloned.inner.address);
}
}