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:
parent
edc976e5d9
commit
2b4fdff0c0
14 changed files with 4389 additions and 167 deletions
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue