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
211
crates/aldabra-mcp/src/bootstrap.rs
Normal file
211
crates/aldabra-mcp/src/bootstrap.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
194
crates/aldabra-mcp/src/config.rs
Normal file
194
crates/aldabra-mcp/src/config.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
119
crates/aldabra-mcp/src/tools.rs
Normal file
119
crates/aldabra-mcp/src/tools.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue