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:
commit
56bcceb593
11 changed files with 735 additions and 0 deletions
23
crates/wallet-chain/Cargo.toml
Normal file
23
crates/wallet-chain/Cargo.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# wallet-chain — pluggable backends for chain queries.
|
||||
#
|
||||
# A `ChainBackend` trait abstracts over Koios (HTTPS), Ogmios (WS/HTTP),
|
||||
# or any future option. Phase 1 ships with a Koios implementation since
|
||||
# Sulkta already runs Koios endpoints.
|
||||
#
|
||||
# Submission paths land in phase 2. Read-only queries first.
|
||||
|
||||
[package]
|
||||
name = "wallet-chain"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license-file.workspace = true
|
||||
repository.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
async-trait = "0.1"
|
||||
97
crates/wallet-chain/src/lib.rs
Normal file
97
crates/wallet-chain/src/lib.rs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
//! 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.
|
||||
//!
|
||||
//! ## Phase 1
|
||||
//! Just the trait + a stub `KoiosClient` that returns hardcoded data.
|
||||
//! Real HTTP wired up next pass.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ChainError {
|
||||
#[error("network error: {0}")]
|
||||
Network(String),
|
||||
|
||||
#[error("backend returned malformed response: {0}")]
|
||||
Decode(String),
|
||||
|
||||
#[error("not yet implemented (phase 1 scaffold)")]
|
||||
NotYetImplemented,
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub struct Utxo {
|
||||
pub tx_hash: String,
|
||||
pub output_index: u32,
|
||||
pub lovelace: u64,
|
||||
/// Hex-encoded `policy_id || asset_name_hex` → quantity.
|
||||
/// Empty for plain ADA UTXOs.
|
||||
pub assets: std::collections::BTreeMap<String, u64>,
|
||||
}
|
||||
|
||||
/// Aggregated balance at an address.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Balance {
|
||||
pub lovelace: u64,
|
||||
pub assets: std::collections::BTreeMap<String, u64>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait ChainBackend: Send + Sync {
|
||||
async fn get_utxos(&self, address: &str) -> Result<Vec<Utxo>, ChainError>;
|
||||
async fn get_balance(&self, address: &str) -> Result<Balance, ChainError>;
|
||||
// Phase 2:
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
35
crates/wallet-core/Cargo.toml
Normal file
35
crates/wallet-core/Cargo.toml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# wallet-core — pure crypto + types. No I/O, no network. Deterministic
|
||||
# given the same mnemonic + derivation path.
|
||||
#
|
||||
# This crate is intentionally narrow:
|
||||
# - Mnemonic → root key
|
||||
# - Root key → payment / stake key (CIP-1852 derivation)
|
||||
# - Address construction (mainnet, testnet)
|
||||
# - Transaction signing (given an unsigned TX builder output from
|
||||
# wallet-chain or pallas-txbuilder)
|
||||
#
|
||||
# It deliberately does NOT:
|
||||
# - Do any chain queries (that's wallet-chain's job)
|
||||
# - Talk to MCP (that's wallet-mcp's job)
|
||||
# - Touch files (the daemon owns disk I/O; we get keys handed in)
|
||||
#
|
||||
# Rationale: this is the most security-sensitive crate. Keeping it
|
||||
# narrow + I/O-free makes it auditable.
|
||||
|
||||
[package]
|
||||
name = "wallet-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license-file.workspace = true
|
||||
repository.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
pallas-primitives = { workspace = true }
|
||||
pallas-codec = { workspace = true }
|
||||
pallas-crypto = { workspace = true }
|
||||
pallas-addresses = { workspace = true }
|
||||
bip39 = { workspace = true }
|
||||
zeroize = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
142
crates/wallet-core/src/lib.rs
Normal file
142
crates/wallet-core/src/lib.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
30
crates/wallet-mcp/Cargo.toml
Normal file
30
crates/wallet-mcp/Cargo.toml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# wallet-mcp — the binary. MCP server speaking stdio that exposes
|
||||
# the wallet's tools to an LLM. Spawned as a subprocess from any MCP
|
||||
# client (e.g. Claude Code).
|
||||
#
|
||||
# Owns: process lifecycle, stdio transport, config loading, glue
|
||||
# between core + chain crates.
|
||||
|
||||
[package]
|
||||
name = "wallet-mcp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license-file.workspace = true
|
||||
repository.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "aldabra"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
wallet-core = { path = "../wallet-core" }
|
||||
wallet-chain = { path = "../wallet-chain" }
|
||||
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
rmcp = { workspace = true }
|
||||
74
crates/wallet-mcp/src/main.rs
Normal file
74
crates/wallet-mcp/src/main.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
//! 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
|
||||
//!
|
||||
//! - `wallet.address` — return the derived base address (placeholder
|
||||
//! until wallet-core's CIP-1852 derivation lands)
|
||||
//! - `wallet.balance` — query balance via the configured chain backend
|
||||
//!
|
||||
//! ## Phase 2-4 tools (TODO)
|
||||
//!
|
||||
//! 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.
|
||||
//!
|
||||
//! ## Logging
|
||||
//!
|
||||
//! Stderr only — stdout is the MCP transport, must stay clean.
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Stderr only — stdout is MCP transport
|
||||
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)");
|
||||
|
||||
// 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).
|
||||
tracing::info!(
|
||||
target_address = %wallet_core::derive_base_address(
|
||||
&dummy_root_key()?,
|
||||
wallet_core::Network::Mainnet,
|
||||
0,
|
||||
0,
|
||||
)?,
|
||||
"scaffold smoke test — derived placeholder address",
|
||||
);
|
||||
|
||||
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<wallet_core::RootKey> {
|
||||
// Need a way to construct one from this crate without exposing
|
||||
// private fields. Phase 1: temporary public constructor on
|
||||
// wallet-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 wallet-core::Mnemonic::into_root_key.
|
||||
anyhow::bail!("phase 1 scaffold: real mnemonic loading not yet implemented")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue