HIGH:
- HIGH-1 enforce_value_cap helper applied to wallet.send,
wallet.mint, wallet.mint.cip68_nft, wallet.script.spend. each
gained a `force` arg; cap also covers the user_lovelace+ref_lovelace
sum on cip68_nft. wallet.stake.delegate skipped (2 ada deposit is
protocol-fixed, not a transfer to a non-wallet destination).
- HIGH-2 wallet.tx_summary mcp tool — read-only decode of a conway
tx cbor → typed TxSummary (inputs, outputs+assets, fee, certs,
mint, witness count, aux-data presence). new aldabra-core::inspect
module. callers MUST run this before wallet.sign_partial /
wallet.submit_signed_tx on any cbor they didn't build themselves.
MEDIUM:
- M-1 zeroize stack-resident extended_bytes after SecretKeyExtended
consumes them. tx.rs::payment_key_to_private + sign.rs::add_witness.
- M-2 atomic 0o600 mnemonic file create via OpenOptions+
OpenOptionsExt. removes the prior toctou window between fs::write
(default umask) and chmod 600.
- M-3 prompt_or_env_passphrase + unlock_passphrase helpers wrap the
passphrase in Zeroizing<String>. ALDABRA_PASSPHRASE env still
unzeroizable in the env block itself (documented headless tradeoff).
- M-4 is_hex_64 validator on submit_tx response — koios error wrapped
in quotes can no longer round-trip as a fake tx_hash.
LOW + cleanup:
- L-1 checked_add for inner sums of checked_sub patterns in tx.rs.
remaining sites (mint.rs, stake.rs, plutus.rs) deferred — same
pattern, can't overflow with realistic cardano amounts but
defensive. picked up next.
- L-2 root key scoped to a block in main.rs — XPrv drops + wipes
after deriving payment_key + stake_key + address. saves ~96 bytes
of secret material lifetime.
- L-3 TxStatus gained a Pending variant for the mempool-but-not-yet-
confirmed case. previously rendered as Confirmed{block_height: None}
which was misleading.
- L-4 .expect("we built this key") → typed ? propagation in
tx.rs::prepare_payment.
- L-5 removed dead fns (build_and_sign, decode_hex) + unused imports.
WALLET GENERATION (audit prompted gap-find):
aldabra had only an import path. no "generate fresh wallet" tool.
- Mnemonic::generate() — bip39::Mnemonic::generate_in(English, 24)
with the rand feature. returns (Mnemonic, Zeroizing<String>) so
the caller can display the phrase once for cold backup.
- aldabra --generate-mnemonic — print fresh phrase, exit. no disk.
- aldabra --bootstrap-new — generate + display + encrypt one-shot.
- bip39 dep gains the rand feature for OsRng-backed generation.
- standard 24-word BIP-39, recoverable from any cardano wallet.
mcp tools: 16 → 17 (added wallet.tx_summary).
unit tests: 88 → 93. cargo audit clean (0 cves), cargo build clean
(0 warnings). all four cli flags smoke-tested:
--generate-mnemonic prints + exits; --bootstrap-new generates +
encrypts + derives a real preprod address; mnemonic.age has 0o600
perms confirmed atomic.
audit doc internal notes updated with
status markers.
135 lines
4.2 KiB
Rust
135 lines
4.2 KiB
Rust
//! 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.
|
|
//!
|
|
//! ## Phase 1 tools (target — server wiring lands in 1.7)
|
|
//!
|
|
//! - `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
|
|
//!
|
|
//! 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() -> 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,
|
|
account = cfg.account,
|
|
index = cfg.index,
|
|
data_dir = %cfg.data_dir.display(),
|
|
"aldabra starting"
|
|
);
|
|
|
|
// 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).
|
|
// (none) — load existing, start MCP server.
|
|
let args: Vec<String> = 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");
|
|
|
|
if generate_only {
|
|
bootstrap::print_fresh_mnemonic()?;
|
|
return Ok(());
|
|
}
|
|
|
|
let mnemonic_path = bootstrap::mnemonic_path(&cfg.data_dir);
|
|
|
|
// L-2 audit fix: 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 mnemonic_path.exists() {
|
|
bootstrap::load_or_create_root_key(&cfg.data_dir)?
|
|
} else if bootstrap_only {
|
|
bootstrap::load_or_create_root_key(&cfg.data_dir)?
|
|
} else {
|
|
anyhow::bail!(
|
|
"no mnemonic at {}. run `aldabra --bootstrap` (paste existing) \
|
|
or `aldabra --bootstrap-new` (generate fresh) first.",
|
|
mnemonic_path.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,
|
|
payment_key,
|
|
stake_key,
|
|
cfg.max_send_lovelace,
|
|
);
|
|
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(())
|
|
}
|