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

@ -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")
}