phase 1 scaffold: cargo workspace + 3 crates + roadmap + architecture

Repo skeleton for aldabra, the rust-native cardano lite wallet
with MCP server interface. Builds end-to-end, types in place,
real cardano primitives land next pass.

Crates:
  wallet-core   — pure crypto + types. mnemonic, key derivation,
                  signing. No I/O. Security boundary.
  wallet-chain  — pluggable backends. ChainBackend trait, Koios
                  client (stub for now). Ogmios + submit in phase 2.
  wallet-mcp    — the binary. stdio MCP transport via rmcp.

Phase plan in ROADMAP.md, threat model in docs/architecture.md.

This is also Sulkta's first Rust project + a real-world workout for
ci-runner's rust toolchain.
This commit is contained in:
Sulkta 2026-05-04 10:02:32 -07:00
commit 56bcceb593
11 changed files with 735 additions and 0 deletions

View file

@ -0,0 +1,142 @@
//! aldabra core — keys, addresses, signing.
//!
//! 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)
//!
//! - [`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
//!
//! ## Phase 1 (this scaffold)
//!
//! Just types + a placeholder address-derivation function. Real impl
//! lands as we wire up `pallas-crypto`'s key derivation API.
//!
//! ## 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.
use thiserror::Error;
use zeroize::ZeroizeOnDrop;
#[derive(Debug, Error)]
pub enum WalletError {
#[error("invalid mnemonic: {0}")]
InvalidMnemonic(String),
#[error("derivation failed: {0}")]
Derivation(String),
#[error("address encoding failed: {0}")]
Address(String),
#[error("not yet implemented (phase 1 scaffold)")]
NotYetImplemented,
}
/// A 24-word BIP-39 mnemonic. Held in memory only while deriving keys;
/// callers should drop this immediately after `derive_root_key`.
#[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,
}
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.
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(),
));
}
Ok(Self {
phrase: phrase.to_string(),
})
}
/// Derive the Cardano CIP-3 root key from this mnemonic. Consumes
/// the mnemonic so the source phrase 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)
}
}
/// CIP-3 root key. Holds the seed material from which payment + stake
/// keys are derived via CIP-1852 paths. Zeroized on drop.
#[derive(ZeroizeOnDrop)]
pub struct RootKey {
/// 96 bytes per CIP-3 (extended secret + chain code).
bytes: [u8; 96],
}
/// Network parameter — bech32 prefix + protocol magic.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Network {
Mainnet,
Preview,
Preprod,
}
impl Network {
pub fn bech32_hrp_prefix(&self) -> &'static str {
match self {
Network::Mainnet => "addr",
Network::Preview | Network::Preprod => "addr_test",
}
}
}
/// Derive a base address (payment + stake) at account 0, address index 0.
///
/// # 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.
pub fn derive_base_address(
_root: &RootKey,
network: Network,
_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"))
}
#[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());
}
#[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"));
}
}