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:
Cobb 2026-05-04 11:09:00 -07:00
parent 1f1993ed97
commit bc39148b63
14 changed files with 4389 additions and 167 deletions

9
.dockerignore Normal file
View file

@ -0,0 +1,9 @@
target
**/target
.git
.gitignore
*.age
.openclaw-test*
README-build.md
Dockerfile
.dockerignore

2956
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -41,9 +41,18 @@ pallas-addresses = "0.32"
pallas-txbuilder = "0.32"
pallas-network = "0.32"
# Mnemonic + key derivation. bip39 for the wordlist, then pallas-crypto
# handles the Cardano-specific CIP-3 / CIP-1852 derivation paths.
# Mnemonic + key derivation.
# bip39 — 24-word wordlist parsing + BIP-39 entropy extraction.
# ed25519-bip32 — Cardano's variant of BIP-32-Ed25519 HD derivation
# (XPrv + DerivationScheme::V2 hard/soft children).
# pallas-crypto only ships raw ed25519, not HD derivation.
# cryptoxide — PBKDF2-HMAC-SHA512 for Icarus master-key generation
# (CIP-3). Already pulled in transitively by
# ed25519-bip32; declared here so we can use pbkdf2 + Sha512
# directly in aldabra-core.
bip39 = "2"
ed25519-bip32 = "0.4"
cryptoxide = "0.4"
# At-rest encryption for the mnemonic + derived keys on disk. age is
# what the cauldron Fernet pattern would have been if we'd had it back
@ -73,3 +82,10 @@ rmcp = { version = "0.1", features = ["server", "transport-io"] }
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Config file parsing — TOML at $ALDABRA_DATA/config.toml.
toml = "0.9"
# Hidden-input passphrase prompts for the mnemonic bootstrap CLI.
# rpassword is the standard "tty echo off" prompt crate.
rpassword = "7"

72
Dockerfile Normal file
View file

@ -0,0 +1,72 @@
# aldabra — Cardano lite wallet over MCP.
#
# Multi-stage:
# 1. builder — rust toolchain, cargo build --release
# 2. runtime — debian:bookworm-slim, just the binary + ca-certs.
#
# Built nightly on Lucy (see lucy-infra/scripts/nightly-builds.sh) and
# published as `lucy-registry:5000/aldabra/mcp:{SHA,latest}`. Pulled
# anywhere we want the MCP server available — usually as a sidecar
# spawned by an MCP client (Claude Code, OpenClaw).
#
# Required env at runtime:
# ALDABRA_DATA directory containing mnemonic.age (must
# already exist; bootstrap separately on
# first install)
# ALDABRA_NETWORK mainnet | preview | preprod (default preprod)
# ALDABRA_KOIOS_BASE defaults to public Koios for the network
# ALDABRA_PASSPHRASE unlocks mnemonic.age. Source from a docker
# secret or systemd EnvironmentFile — never
# commit it.
FROM rust:1.95-bookworm AS builder
WORKDIR /build
# Cache deps separately from source. Copy manifests + dummy bins so
# `cargo build` resolves and downloads everything before the real
# source rebuilds invalidate the layer.
COPY Cargo.toml ./
COPY crates/aldabra-core/Cargo.toml crates/aldabra-core/
COPY crates/aldabra-chain/Cargo.toml crates/aldabra-chain/
COPY crates/aldabra-mcp/Cargo.toml crates/aldabra-mcp/
RUN mkdir -p crates/aldabra-core/src crates/aldabra-chain/src crates/aldabra-mcp/src && \
echo 'fn main() {}' > crates/aldabra-mcp/src/main.rs && \
echo '' > crates/aldabra-core/src/lib.rs && \
echo '' > crates/aldabra-chain/src/lib.rs && \
cargo build --release --bin aldabra || true && \
rm -rf crates/*/src
COPY crates ./crates
# Touch every src file so cargo notices and rebuilds. The dummy-source
# trick above leaves stale build artifacts otherwise.
RUN find crates -name '*.rs' -exec touch {} +
RUN cargo build --release --bin aldabra && \
strip target/release/aldabra
FROM debian:bookworm-slim AS runtime
# rustls-tls needs ca-certificates to verify Koios's TLS cert.
# tini for proper signal forwarding when running as PID 1.
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates tini && \
rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/target/release/aldabra /usr/local/bin/aldabra
# Default data dir — mount a volume here in compose / k8s / docker run.
ENV ALDABRA_DATA=/var/lib/aldabra
RUN mkdir -p /var/lib/aldabra && chmod 700 /var/lib/aldabra
# Non-root user for runtime. Mnemonic.age is owner-readable only
# (chmod 600 from the bootstrap path), so the runtime UID must own
# the data dir.
RUN groupadd -r aldabra && useradd -r -g aldabra -d /var/lib/aldabra aldabra && \
chown -R aldabra:aldabra /var/lib/aldabra
USER aldabra
# tini handles SIGTERM and reaps zombies.
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/aldabra"]

View file

@ -0,0 +1,328 @@
//! Koios REST client — POST queries against the Cardano node feature
//! set Koios provides on top of cardano-db-sync.
//!
//! All Koios POST endpoints take a body of `{"_addresses": [...]}`
//! plus optional flags. We use `/address_utxos` for the UTXO set and
//! `/address_info` for the aggregate balance + nested UTXO snapshot.
//!
//! Numeric quantities come back as strings — Cardano amounts are
//! uint64s and JSON-as-spec doesn't safely round-trip those through
//! a JS Number. We parse them into `u64` here.
//!
//! ## Endpoint URLs
//!
//! - mainnet: `https://api.koios.rest/api/v1`
//! - preprod: `https://preprod.koios.rest/api/v1`
//! - preview: `https://preview.koios.rest/api/v1`
//!
//! Sulkta-hosted Koios on Rackham (when it lands) drops in here too —
//! it's the same API.
use std::collections::BTreeMap;
use std::time::Duration;
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use crate::{Balance, ChainBackend, ChainError, Utxo};
/// Default timeout for a single Koios HTTP call. 10 s covers the
/// public mainnet endpoint's worst case.
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Serialize)]
struct AddressesBody<'a> {
#[serde(rename = "_addresses")]
addresses: Vec<&'a str>,
}
#[derive(Deserialize)]
struct KoiosAsset {
policy_id: String,
/// Hex-encoded asset name (NOT bech32 fingerprint).
asset_name: String,
/// uint64 wrapped in a string per Koios conventions.
quantity: String,
}
#[derive(Deserialize)]
struct KoiosUtxo {
tx_hash: String,
tx_index: u32,
/// Lovelace at this UTXO, uint64 in a string.
value: String,
#[serde(default)]
asset_list: Vec<KoiosAsset>,
}
#[derive(Deserialize)]
struct KoiosAddressInfo {
/// Total lovelace at this address.
balance: String,
#[serde(default)]
utxo_set: Vec<KoiosUtxo>,
}
pub struct KoiosClient {
base_url: String,
http: Client,
}
impl KoiosClient {
/// Construct a client with the default 10-second timeout.
pub fn new(base_url: impl Into<String>) -> Self {
Self::with_timeout(base_url, DEFAULT_TIMEOUT)
}
/// Construct a client with a custom request timeout.
pub fn with_timeout(base_url: impl Into<String>, timeout: Duration) -> Self {
Self {
base_url: base_url.into(),
http: Client::builder()
.timeout(timeout)
.build()
.expect("reqwest client builds with rustls + json features"),
}
}
fn url(&self, path: &str) -> String {
format!("{}/{}", self.base_url.trim_end_matches('/'), path)
}
async fn post_json<T, R>(&self, path: &str, body: &T) -> Result<R, ChainError>
where
T: Serialize,
R: for<'de> Deserialize<'de>,
{
self.http
.post(self.url(path))
.json(body)
.send()
.await
.map_err(|e| ChainError::Network(e.to_string()))?
.error_for_status()
.map_err(|e| ChainError::Network(e.to_string()))?
.json::<R>()
.await
.map_err(|e| ChainError::Decode(e.to_string()))
}
}
fn parse_u64(s: &str, field: &str) -> Result<u64, ChainError> {
s.parse::<u64>()
.map_err(|e| ChainError::Decode(format!("{field}: {e} (got {s:?})")))
}
fn asset_key(policy_id: &str, asset_name_hex: &str) -> String {
let mut k = String::with_capacity(policy_id.len() + asset_name_hex.len());
k.push_str(policy_id);
k.push_str(asset_name_hex);
k
}
fn convert_utxo(k: KoiosUtxo) -> Result<Utxo, ChainError> {
let lovelace = parse_u64(&k.value, "utxo.value")?;
let mut assets = BTreeMap::new();
for a in k.asset_list {
let qty = parse_u64(&a.quantity, "utxo.asset.quantity")?;
assets.insert(asset_key(&a.policy_id, &a.asset_name), qty);
}
Ok(Utxo {
tx_hash: k.tx_hash,
output_index: k.tx_index,
lovelace,
assets,
})
}
#[async_trait]
impl ChainBackend for KoiosClient {
async fn get_utxos(&self, address: &str) -> Result<Vec<Utxo>, ChainError> {
let body = AddressesBody { addresses: vec![address] };
let raw: Vec<KoiosUtxo> = self.post_json("address_utxos", &body).await?;
raw.into_iter().map(convert_utxo).collect()
}
async fn get_balance(&self, address: &str) -> Result<Balance, ChainError> {
let body = AddressesBody { addresses: vec![address] };
let raw: Vec<KoiosAddressInfo> = self.post_json("address_info", &body).await?;
// Empty array = address has no on-chain history yet — treat
// as a zero balance rather than an error. Match Koios's own
// semantics.
let Some(info) = raw.into_iter().next() else {
return Ok(Balance {
lovelace: 0,
assets: BTreeMap::new(),
});
};
let lovelace = parse_u64(&info.balance, "address_info.balance")?;
let mut assets: BTreeMap<String, u64> = BTreeMap::new();
for u in info.utxo_set {
for a in u.asset_list {
let qty = parse_u64(&a.quantity, "address_info.utxo.asset.quantity")?;
let key = asset_key(&a.policy_id, &a.asset_name);
let entry = assets.entry(key).or_insert(0);
*entry = entry.saturating_add(qty);
}
}
Ok(Balance { lovelace, assets })
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Hand-crafted Koios `/address_utxos` response shape — verifies
/// our deserialize path without hitting the network.
const SAMPLE_UTXOS: &str = r#"[
{
"tx_hash": "1a2b3c4d5e6f00000000000000000000000000000000000000000000000000aa",
"tx_index": 0,
"value": "1500000",
"asset_list": []
},
{
"tx_hash": "1a2b3c4d5e6f00000000000000000000000000000000000000000000000000bb",
"tx_index": 1,
"value": "10000000",
"asset_list": [
{
"policy_id": "ee0a1234",
"asset_name": "deadbeef",
"quantity": "42"
}
]
}
]"#;
const SAMPLE_ADDRESS_INFO: &str = r#"[
{
"address": "addr1...",
"balance": "11500000",
"stake_address": "stake1...",
"script_address": false,
"utxo_set": [
{
"tx_hash": "1a2b3c4d5e6f00000000000000000000000000000000000000000000000000aa",
"tx_index": 0,
"value": "1500000",
"asset_list": []
},
{
"tx_hash": "1a2b3c4d5e6f00000000000000000000000000000000000000000000000000bb",
"tx_index": 1,
"value": "10000000",
"asset_list": [
{
"policy_id": "ee0a1234",
"asset_name": "deadbeef",
"quantity": "42"
}
]
}
]
}
]"#;
#[test]
fn deserializes_utxo_response() {
let raw: Vec<KoiosUtxo> = serde_json::from_str(SAMPLE_UTXOS).unwrap();
let utxos: Vec<Utxo> = raw.into_iter().map(convert_utxo).collect::<Result<_, _>>().unwrap();
assert_eq!(utxos.len(), 2);
assert_eq!(utxos[0].lovelace, 1_500_000);
assert!(utxos[0].assets.is_empty());
assert_eq!(utxos[1].lovelace, 10_000_000);
assert_eq!(utxos[1].assets.get("ee0a1234deadbeef"), Some(&42));
}
#[test]
fn deserializes_address_info_response() {
let raw: Vec<KoiosAddressInfo> = serde_json::from_str(SAMPLE_ADDRESS_INFO).unwrap();
assert_eq!(raw.len(), 1);
assert_eq!(raw[0].balance, "11500000");
assert_eq!(raw[0].utxo_set.len(), 2);
}
/// Two UTXOs holding the same asset must aggregate into a single
/// balance entry — protects against the get/insert bug where the
/// running total gets clobbered.
#[test]
fn balance_aggregates_same_asset_across_utxos() {
const TWO_UTXOS_SAME_ASSET: &str = r#"[
{
"address": "addr1...",
"balance": "20000000",
"stake_address": null,
"script_address": false,
"utxo_set": [
{
"tx_hash": "00aa",
"tx_index": 0,
"value": "10000000",
"asset_list": [
{"policy_id": "ee0a1234", "asset_name": "deadbeef", "quantity": "100"}
]
},
{
"tx_hash": "00bb",
"tx_index": 1,
"value": "10000000",
"asset_list": [
{"policy_id": "ee0a1234", "asset_name": "deadbeef", "quantity": "23"}
]
}
]
}
]"#;
let raw: Vec<KoiosAddressInfo> = serde_json::from_str(TWO_UTXOS_SAME_ASSET).unwrap();
let info = raw.into_iter().next().unwrap();
let mut assets: BTreeMap<String, u64> = BTreeMap::new();
for u in info.utxo_set {
for a in u.asset_list {
let qty = parse_u64(&a.quantity, "test").unwrap();
let key = asset_key(&a.policy_id, &a.asset_name);
let entry = assets.entry(key).or_insert(0);
*entry = entry.saturating_add(qty);
}
}
assert_eq!(assets.get("ee0a1234deadbeef"), Some(&123));
}
#[test]
fn parse_u64_rejects_garbage() {
let err = parse_u64("not-a-number", "test").unwrap_err();
match err {
ChainError::Decode(msg) => assert!(msg.contains("test")),
other => panic!("expected Decode, got {other:?}"),
}
}
#[test]
fn url_helper_handles_trailing_slash() {
let c = KoiosClient::new("https://api.koios.rest/api/v1/");
assert_eq!(
c.url("address_info"),
"https://api.koios.rest/api/v1/address_info"
);
}
/// Live network test against the public Koios mainnet endpoint.
/// Marked `#[ignore]` so `cargo test` skips it; run with
/// `cargo test -- --ignored live_koios_round_trip` to exercise.
#[tokio::test]
#[ignore]
async fn live_koios_round_trip() {
// A well-known mainnet address with stable history (IOG
// genesis treasury — historical).
let known_addr = "addr1q9zd6lvqu63rynk3kmzv0aphukk23gn37vfaq8e5kpdg45fkfsdfh67aae3eag2u4d97n6sm5qzcfmsrcgujhppfvxasn0nwt7";
let client = KoiosClient::new("https://api.koios.rest/api/v1");
let result = client.get_balance(known_addr).await;
// We don't assert a specific balance — just that the
// request shape is valid and the response decodes.
assert!(result.is_ok(), "live balance call failed: {:?}", result.err());
}
}

View file

@ -1,15 +1,28 @@
//! aldabra chain backends — Koios first, Ogmios next.
//!
//! Trait-first design: the MCP server depends on `ChainBackend`, not on
//! a specific implementation. Swapping Koios → Ogmios is a config change.
//! Trait-first design: the MCP server depends on [`ChainBackend`], not
//! on a specific implementation. Swapping Koios → Ogmios is a config
//! change.
//!
//! ## Phase 1
//! Just the trait + a stub `KoiosClient` that returns hardcoded data.
//! Real HTTP wired up next pass.
//! Phase 1: read-only queries (`get_utxos`, `get_balance`) against
//! Koios over HTTPS.
//!
//! Phase 2 (TODO): submission paths — `submit_tx`, `tx_status`.
//!
//! ## Backends
//!
//! - [`koios::KoiosClient`] — Koios REST client (POST `/address_utxos`,
//! `/address_info`). Sulkta runs its own Koios on Rackham; the
//! public `https://api.koios.rest/api/v1` works as a fallback.
//! - Ogmios (TODO) — websocket client.
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub mod koios;
pub use koios::KoiosClient;
#[derive(Debug, Error)]
pub enum ChainError {
#[error("network error: {0}")]
@ -23,9 +36,9 @@ pub enum ChainError {
}
/// One UTXO at an address. Multi-asset bundle is a flat map of
/// {policy_id+asset_name → quantity} for now; we'll model it more
/// strictly when minting lands in phase 3.
#[derive(Debug, Clone, Serialize, Deserialize)]
/// `policy_id || asset_name_hex` → quantity for now. We'll model it
/// more strictly when minting lands in phase 3.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Utxo {
pub tx_hash: String,
pub output_index: u32,
@ -36,7 +49,7 @@ pub struct Utxo {
}
/// Aggregated balance at an address.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Balance {
pub lovelace: u64,
pub assets: std::collections::BTreeMap<String, u64>,
@ -50,48 +63,3 @@ pub trait ChainBackend: Send + Sync {
// async fn submit_tx(&self, raw_tx_cbor: &[u8]) -> Result<String, ChainError>;
// async fn tx_status(&self, tx_hash: &str) -> Result<TxStatus, ChainError>;
}
/// Stub Koios client. Phase 1: returns deterministic placeholder data
/// so the MCP server can be smoke-tested end-to-end without a chain
/// dependency. Phase 2: real reqwest calls to a Koios endpoint.
pub struct KoiosClient {
/// Base URL — typically https://api.koios.rest/api/v1
/// or your own self-hosted Koios deployment.
pub base_url: String,
}
impl KoiosClient {
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
}
}
}
#[async_trait::async_trait]
impl ChainBackend for KoiosClient {
async fn get_utxos(&self, _address: &str) -> Result<Vec<Utxo>, ChainError> {
// TODO(phase 1): POST /address_utxos with {"_addresses": [<address>]}
Ok(vec![])
}
async fn get_balance(&self, _address: &str) -> Result<Balance, ChainError> {
// TODO(phase 1): POST /address_info, sum balances across UTXOs
Ok(Balance {
lovelace: 0,
assets: Default::default(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn stub_koios_returns_empty() {
let client = KoiosClient::new("https://api.koios.rest/api/v1");
let bal = client.get_balance("addr1...").await.unwrap();
assert_eq!(bal.lovelace, 0);
}
}

View file

@ -30,6 +30,8 @@ pallas-codec = { workspace = true }
pallas-crypto = { workspace = true }
pallas-addresses = { workspace = true }
bip39 = { workspace = true }
ed25519-bip32 = { workspace = true }
cryptoxide = { workspace = true }
zeroize = { workspace = true }
thiserror = { workspace = true }
serde = { workspace = true }

View file

@ -0,0 +1,168 @@
//! CIP-1852 child key derivation.
//!
//! Cardano hardened-account / soft-chain HD paths land here. The
//! external surface is two key types ([`PaymentKey`], [`StakeKey`])
//! and two derivation entry points ([`derive_payment_key`],
//! [`derive_stake_key`]).
//!
//! ## CIP-1852 path layout
//!
//! ```text
//! m / 1852' / 1815' / account' / chain / index
//! ```
//!
//! - `1852'` — purpose (Cardano Shelley)
//! - `1815'` — Cardano coin type (Ada Lovelace's birth year)
//! - `account'` — hardened account index, usually 0
//! - `chain` — `0` external (payment), `1` internal (change),
//! `2` stake key
//! - `index` — soft index within the chain
//!
//! ## Hardened indexing
//!
//! BIP-32 hardened indices are `index | 0x8000_0000`. The first
//! three components of the path above are hardened; the last two
//! (`chain`, `index`) are soft.
//!
//! ## Why a separate module
//!
//! Keeping derivation in its own file means [`lib.rs`] stays focused
//! on the security-boundary types (Mnemonic, RootKey). New chain
//! types (Byron, future Conway-era keys) plug in here without
//! mutating the root crate.
use ed25519_bip32::{DerivationScheme, XPrv};
use pallas_crypto::hash::{Hash, Hasher};
use crate::RootKey;
const SCHEME: DerivationScheme = DerivationScheme::V2;
/// Hardened-bit OR mask. BIP-32 hardened indices are `n | HARDENED`.
const HARDENED: u32 = 0x8000_0000;
/// CIP-1852 purpose constant: Shelley.
const PURPOSE: u32 = HARDENED | 1852;
/// Cardano coin type per SLIP-44 / CIP-1852.
const COIN_TYPE: u32 = HARDENED | 1815;
/// Chain index for external (payment) addresses per CIP-1852.
const CHAIN_PAYMENT: u32 = 0;
/// Chain index for stake keys per CIP-1852.
const CHAIN_STAKE: u32 = 2;
/// A payment key derived at `m/1852'/1815'/account'/0/index`. Wraps
/// an [`XPrv`] whose own [`Drop`] impl wipes the bytes.
pub struct PaymentKey {
xprv: XPrv,
}
impl PaymentKey {
/// Blake2b-224 hash of the 32-byte raw public key — the canonical
/// payment-key hash that goes into a Shelley base address's
/// payment part.
pub fn public_key_hash(&self) -> Hash<28> {
Hasher::<224>::hash(self.xprv.public().public_key_bytes())
}
}
/// A stake key derived at `m/1852'/1815'/account'/2/0`. Same memory
/// hygiene as [`PaymentKey`].
pub struct StakeKey {
xprv: XPrv,
}
impl StakeKey {
/// Blake2b-224 hash of the raw stake public key — goes into the
/// delegation part of a Shelley base address.
pub fn public_key_hash(&self) -> Hash<28> {
Hasher::<224>::hash(self.xprv.public().public_key_bytes())
}
}
/// Derive a payment key at `m/1852'/1815'/account'/0/index`.
pub fn derive_payment_key(root: &RootKey, account: u32, index: u32) -> PaymentKey {
let xprv = root
.xprv()
.derive(SCHEME, PURPOSE)
.derive(SCHEME, COIN_TYPE)
.derive(SCHEME, HARDENED | account)
.derive(SCHEME, CHAIN_PAYMENT)
.derive(SCHEME, index);
PaymentKey { xprv }
}
/// Derive the account stake key at `m/1852'/1815'/account'/2/0`.
/// Each account has exactly one stake key (chain index 2, soft 0).
pub fn derive_stake_key(root: &RootKey, account: u32) -> StakeKey {
let xprv = root
.xprv()
.derive(SCHEME, PURPOSE)
.derive(SCHEME, COIN_TYPE)
.derive(SCHEME, HARDENED | account)
.derive(SCHEME, CHAIN_STAKE)
.derive(SCHEME, 0);
StakeKey { xprv }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Mnemonic;
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",
);
fn root_from_canonical() -> RootKey {
Mnemonic::from_phrase(ABANDON_ART)
.unwrap()
.into_root_key()
.unwrap()
}
#[test]
fn payment_and_stake_key_hashes_are_28_bytes() {
let root = root_from_canonical();
let payment = derive_payment_key(&root, 0, 0);
let stake = derive_stake_key(&root, 0);
// Hash<28> is a strong type, but we still want to confirm the
// raw byte length matches what pallas-addresses expects.
assert_eq!(payment.public_key_hash().as_ref().len(), 28);
assert_eq!(stake.public_key_hash().as_ref().len(), 28);
}
#[test]
fn derivation_is_deterministic() {
let root_a = root_from_canonical();
let root_b = root_from_canonical();
let pa = derive_payment_key(&root_a, 0, 0);
let pb = derive_payment_key(&root_b, 0, 0);
assert_eq!(pa.public_key_hash(), pb.public_key_hash());
}
#[test]
fn account_and_index_change_the_payment_hash() {
let root = root_from_canonical();
let p_0_0 = derive_payment_key(&root, 0, 0);
let p_0_1 = derive_payment_key(&root, 0, 1);
let p_1_0 = derive_payment_key(&root, 1, 0);
assert_ne!(p_0_0.public_key_hash(), p_0_1.public_key_hash());
assert_ne!(p_0_0.public_key_hash(), p_1_0.public_key_hash());
}
#[test]
fn payment_and_stake_keys_differ_at_same_account() {
let root = root_from_canonical();
let payment = derive_payment_key(&root, 0, 0);
let stake = derive_stake_key(&root, 0);
// Same account but chain index 0 vs 2 — must produce
// different key hashes.
assert_ne!(payment.public_key_hash(), stake.public_key_hash());
}
}

View file

@ -3,28 +3,41 @@
//! This crate is the security boundary. Everything that touches private
//! key material lives here, and only here. No I/O, no network, no MCP.
//!
//! ## Layout (target)
//! ## Layout
//!
//! - [`mnemonic`] — 24-word BIP-39 input → root key (CIP-3)
//! - [`derive`] — Root key → payment + stake key (CIP-1852 paths)
//! - [`address`] — Public keys → bech32 addresses (mainnet / testnet)
//! - [`signing`] — Sign an unsigned transaction body
//! - [`Mnemonic`] — 24-word BIP-39 input → entropy bytes.
//! - [`Mnemonic::into_root_key`] — Icarus CIP-3 master-key generation.
//! - [`RootKey`] — wraps [`ed25519_bip32::XPrv`].
//! - [`Network`] — bech32 prefix + protocol magic selector.
//!
//! ## Phase 1 (this scaffold)
//!
//! Just types + a placeholder address-derivation function. Real impl
//! lands as we wire up `pallas-crypto`'s key derivation API.
//! Phases 1.3 (CIP-1852 child derivation), 1.4 (real base-address
//! construction), and signing land in follow-up modules; the placeholder
//! [`derive_base_address`] returns a sentinel address until then.
//!
//! ## Memory hygiene rule
//!
//! Anything that holds a private key MUST `derive(ZeroizeOnDrop)` or
//! manually zeroize when going out of scope. Use the `zeroize` crate.
//! This is non-negotiable — RAM-resident keys leak via core dumps,
//! swap, hibernate state, etc.
//! Anything holding private-key material zeroizes on drop:
//! - [`Mnemonic`]'s entropy via `ZeroizeOnDrop`.
//! - [`RootKey`]'s [`XPrv`] via its own [`Drop`] impl in `ed25519-bip32`.
//!
//! The decrypted phrase passed into [`Mnemonic::from_phrase`] is the
//! caller's responsibility to drop promptly — we copy the entropy out
//! and don't hold the source string.
use bip39::{Language, Mnemonic as Bip39Mnemonic};
use cryptoxide::hmac::Hmac;
use cryptoxide::pbkdf2::pbkdf2;
use cryptoxide::sha2::Sha512;
use ed25519_bip32::{XPrv, XPRV_SIZE};
use pallas_addresses::{
Network as PallasNetwork, ShelleyAddress, ShelleyDelegationPart, ShelleyPaymentPart,
};
use thiserror::Error;
use zeroize::ZeroizeOnDrop;
pub mod derive;
pub use derive::{derive_payment_key, derive_stake_key, PaymentKey, StakeKey};
#[derive(Debug, Error)]
pub enum WalletError {
#[error("invalid mnemonic: {0}")]
@ -40,53 +53,87 @@ pub enum WalletError {
NotYetImplemented,
}
/// A 24-word BIP-39 mnemonic. Held in memory only while deriving keys;
/// callers should drop this immediately after `derive_root_key`.
/// A 24-word BIP-39 mnemonic, parsed and validated. Stores the raw
/// 32-byte entropy rather than the phrase — the source string is the
/// caller's responsibility to drop.
///
/// `ZeroizeOnDrop` ensures the entropy is wiped from RAM when this
/// struct is dropped.
#[derive(ZeroizeOnDrop)]
pub struct Mnemonic {
/// Stored as a single string (joined with spaces). The
/// `ZeroizeOnDrop` derive ensures this gets wiped from RAM when
/// the struct is dropped.
phrase: String,
/// 256 bits of entropy (24 BIP-39 words × 11 bits = 264 bits, the
/// trailing 8 are the checksum). The bip39 crate's `to_entropy()`
/// returns exactly the 32 entropy bytes.
entropy: [u8; 32],
}
impl Mnemonic {
/// Parse a 24-word mnemonic from a whitespace-separated string.
/// Validates word count + checksum via the `bip39` crate.
///
/// # Phase 1
/// TODO: wire up `bip39::Mnemonic::parse_in` once we lock the
/// API. For now this just stores the phrase verbatim — DO NOT
/// rely on validation yet.
/// Parse a 24-word English mnemonic, validating word count + checksum.
/// Drops the source phrase reference immediately after extracting
/// entropy.
pub fn from_phrase(phrase: &str) -> Result<Self, WalletError> {
// TODO(phase 1): real validation
if phrase.split_whitespace().count() != 24 {
return Err(WalletError::InvalidMnemonic(
"expected 24 words".into(),
));
let parsed = Bip39Mnemonic::parse_in(Language::English, phrase)
.map_err(|e| WalletError::InvalidMnemonic(e.to_string()))?;
if parsed.word_count() != 24 {
return Err(WalletError::InvalidMnemonic(format!(
"expected 24 words, got {}",
parsed.word_count()
)));
}
Ok(Self {
phrase: phrase.to_string(),
})
let entropy_vec = parsed.to_entropy();
let entropy: [u8; 32] = entropy_vec.try_into().map_err(|v: Vec<u8>| {
WalletError::InvalidMnemonic(format!(
"expected 32 entropy bytes for 24-word mnemonic, got {}",
v.len()
))
})?;
Ok(Self { entropy })
}
/// Derive the Cardano CIP-3 root key from this mnemonic. Consumes
/// the mnemonic so the source phrase is dropped + zeroized
/// immediately after.
/// Derive the Cardano CIP-3 root extended private key (Icarus
/// variant, no passphrase). Consumes the mnemonic so the entropy
/// is dropped + zeroized immediately after.
pub fn into_root_key(self) -> Result<RootKey, WalletError> {
// TODO(phase 1): pallas-crypto's PBKDF2 + entropy + chain code
// derivation per CIP-3. Reference:
// https://input-output-hk.github.io/cardano-wallet/concepts/master-key-generation
Err(WalletError::NotYetImplemented)
self.into_root_key_with_passphrase("")
}
/// Derive the Cardano CIP-3 root extended private key with a
/// caller-supplied BIP-39 passphrase. Empty string = no passphrase
/// = the default Icarus / Yoroi behaviour.
///
/// Algorithm (per Cardano Icarus master-key generation):
/// 1. `xprv = PBKDF2-HMAC-SHA512(password=passphrase, salt=entropy,
/// c=4096, dkLen=96)`
/// 2. Bit-clamp the first 32 bytes so the result is a valid extended
/// Ed25519 scalar with the 3rd-highest bit cleared
/// (`normalize_bytes_force3rd`).
pub fn into_root_key_with_passphrase(
self,
passphrase: &str,
) -> Result<RootKey, WalletError> {
let mut xprv_bytes = [0u8; XPRV_SIZE];
let mut hmac = Hmac::new(Sha512::new(), passphrase.as_bytes());
pbkdf2(&mut hmac, &self.entropy, 4096, &mut xprv_bytes);
let xprv = XPrv::normalize_bytes_force3rd(xprv_bytes);
Ok(RootKey { xprv })
}
}
/// CIP-3 root key. Holds the seed material from which payment + stake
/// keys are derived via CIP-1852 paths. Zeroized on drop.
#[derive(ZeroizeOnDrop)]
/// CIP-3 root extended private key. Wraps an [`XPrv`]
/// (96 bytes: extended secret + chain code). [`XPrv`]'s own [`Drop`]
/// impl wipes the bytes from memory when this struct drops.
pub struct RootKey {
/// 96 bytes per CIP-3 (extended secret + chain code).
bytes: [u8; 96],
pub(crate) xprv: XPrv,
}
impl RootKey {
/// Borrow the underlying [`XPrv`] for derivation. Crate-internal
/// code uses this; external callers should go through the
/// `derive_*` helpers which return purpose-specific key types.
pub(crate) fn xprv(&self) -> &XPrv {
&self.xprv
}
}
/// Network parameter — bech32 prefix + protocol magic.
@ -104,39 +151,139 @@ impl Network {
Network::Preview | Network::Preprod => "addr_test",
}
}
/// Map our three-variant Network onto pallas-addresses' two
/// real variants. Cardano's network header byte only distinguishes
/// `Mainnet` from `Testnet` — the protocol magic differentiates
/// Preview vs Preprod at the chain layer, not at the address layer.
/// Both testnet flavours therefore share the `addr_test1…` HRP.
pub fn to_pallas(&self) -> PallasNetwork {
match self {
Network::Mainnet => PallasNetwork::Mainnet,
Network::Preview | Network::Preprod => PallasNetwork::Testnet,
}
}
}
/// Derive a base address (payment + stake) at account 0, address index 0.
/// Derive a Shelley base address (payment + stake) at the given
/// account / payment-index path:
///
/// # Phase 1
/// TODO: real CIP-1852 derivation using pallas-crypto's HD key derivation.
/// For now this returns a placeholder that lets the MCP layer be tested
/// without real keys.
/// - payment path: `m/1852'/1815'/account'/0/index`
/// - stake path: `m/1852'/1815'/account'/2/0`
///
/// Both keys hash through Blake2b-224 to produce 28-byte key hashes,
/// which combine via [`ShelleyPaymentPart::key_hash`] +
/// [`ShelleyDelegationPart::key_hash`] into a Shelley base address,
/// emitted as bech32 with the right HRP for the chosen network.
pub fn derive_base_address(
_root: &RootKey,
root: &RootKey,
network: Network,
_account: u32,
_index: u32,
account: u32,
index: u32,
) -> Result<String, WalletError> {
// TODO(phase 1): real implementation
let prefix = network.bech32_hrp_prefix();
Ok(format!("{prefix}1placeholder_phase_1_scaffold"))
let payment = derive_payment_key(root, account, index);
let stake = derive_stake_key(root, account);
let address = ShelleyAddress::new(
network.to_pallas(),
ShelleyPaymentPart::key_hash(payment.public_key_hash()),
ShelleyDelegationPart::key_hash(stake.public_key_hash()),
);
address
.to_bech32()
.map_err(|e| WalletError::Address(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mnemonic_word_count_validation() {
let too_few = "one two three";
assert!(Mnemonic::from_phrase(too_few).is_err());
/// Canonical 24-word BIP-39 test mnemonic. Used widely in the
/// Cardano ecosystem (cardano-address, cardano-cli docs) so derived
/// vectors are easy to cross-check.
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",
);
/// `Mnemonic` deliberately doesn't `derive(Debug)` — printing the
/// entropy in a panic message would leak key material. Tests use
/// this helper instead of `.unwrap_err()` (which requires `Debug`
/// on the `Ok` variant).
fn expect_invalid(result: Result<Mnemonic, WalletError>) -> WalletError {
match result {
Ok(_) => panic!("expected WalletError::InvalidMnemonic, got Ok(_)"),
Err(e) => e,
}
}
#[test]
fn placeholder_address_has_network_prefix() {
let dummy_root = RootKey { bytes: [0u8; 96] };
let addr = derive_base_address(&dummy_root, Network::Mainnet, 0, 0).unwrap();
assert!(addr.starts_with("addr1"));
fn rejects_short_phrase() {
let err = expect_invalid(Mnemonic::from_phrase("one two three"));
assert!(matches!(err, WalletError::InvalidMnemonic(_)));
}
#[test]
fn rejects_bad_checksum() {
// 24 abandons in a row has a bad checksum — the canonical valid
// form ends in "art".
let bad = "abandon ".repeat(24);
let err = expect_invalid(Mnemonic::from_phrase(bad.trim()));
assert!(matches!(err, WalletError::InvalidMnemonic(_)));
}
#[test]
fn parses_canonical_24_word_mnemonic() {
let m = Mnemonic::from_phrase(ABANDON_ART).expect("valid mnemonic");
// 24 abandon-mostly words → entropy is all zeros.
assert_eq!(m.entropy, [0u8; 32]);
}
#[test]
fn derives_root_key_from_canonical_mnemonic() {
let m = Mnemonic::from_phrase(ABANDON_ART).unwrap();
let root = m.into_root_key().expect("CIP-3 derivation works");
// The derived XPrv must be 96 bytes total and the bit-clamp
// must have cleared the 3rd highest bit at byte 31.
assert_eq!(root.xprv().as_ref().len(), XPRV_SIZE);
assert!(root.xprv().is_3rd_highest_bit_clear());
}
#[test]
fn mainnet_base_address_round_trips() {
let m = Mnemonic::from_phrase(ABANDON_ART).unwrap();
let root = m.into_root_key().unwrap();
let addr = derive_base_address(&root, Network::Mainnet, 0, 0).unwrap();
assert!(addr.starts_with("addr1"), "got: {addr}");
// Round-trip — pallas should parse what we just emitted and
// give back a Shelley mainnet address.
let parsed = pallas_addresses::Address::from_bech32(&addr)
.expect("our own bech32 output parses");
match parsed {
pallas_addresses::Address::Shelley(s) => {
assert_eq!(s.network(), pallas_addresses::Network::Mainnet);
}
other => panic!("expected Shelley address, got {other:?}"),
}
}
#[test]
fn preprod_base_address_uses_testnet_hrp() {
let m = Mnemonic::from_phrase(ABANDON_ART).unwrap();
let root = m.into_root_key().unwrap();
let addr = derive_base_address(&root, Network::Preprod, 0, 0).unwrap();
assert!(addr.starts_with("addr_test1"), "got: {addr}");
}
#[test]
fn different_indices_produce_different_addresses() {
let m = Mnemonic::from_phrase(ABANDON_ART).unwrap();
let root = m.into_root_key().unwrap();
let a0 = derive_base_address(&root, Network::Mainnet, 0, 0).unwrap();
let a1 = derive_base_address(&root, Network::Mainnet, 0, 1).unwrap();
assert_ne!(a0, a1);
}
}

View file

@ -25,6 +25,11 @@ tokio = { workspace = true }
anyhow = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
rmcp = { workspace = true }
age = { workspace = true }
toml = { workspace = true }
rpassword = { workspace = true }
zeroize = { workspace = true }

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 (Claude Code, OpenClaw, etc.)
//! 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);
}
}