From 489b58cc1e3dd83c07eb6a1c9b0d6aad9bd06124 Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 4 May 2026 10:02:32 -0700 Subject: [PATCH] phase 1 scaffold: cargo workspace + 3 crates + roadmap + architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repo skeleton for sulkta-wallet, 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 Cobb's first Rust project + a real-world workout for crafting-table's rust toolchain. --- .gitignore | 18 +++++ Cargo.toml | 70 ++++++++++++++++ LICENSE | 11 +++ README.md | 112 ++++++++++++++++++++++++++ ROADMAP.md | 97 ++++++++++++++++++++++ crates/wallet-chain/Cargo.toml | 23 ++++++ crates/wallet-chain/src/lib.rs | 97 ++++++++++++++++++++++ crates/wallet-core/Cargo.toml | 35 ++++++++ crates/wallet-core/src/lib.rs | 142 +++++++++++++++++++++++++++++++++ crates/wallet-mcp/Cargo.toml | 30 +++++++ crates/wallet-mcp/src/main.rs | 74 +++++++++++++++++ docs/architecture.md | 123 ++++++++++++++++++++++++++++ 12 files changed, 832 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 ROADMAP.md create mode 100644 crates/wallet-chain/Cargo.toml create mode 100644 crates/wallet-chain/src/lib.rs create mode 100644 crates/wallet-core/Cargo.toml create mode 100644 crates/wallet-core/src/lib.rs create mode 100644 crates/wallet-mcp/Cargo.toml create mode 100644 crates/wallet-mcp/src/main.rs create mode 100644 docs/architecture.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..41c1cb6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Rust +target/ +**/*.rs.bk +Cargo.lock.bk + +# Editor +.idea/ +.vscode/ +*.swp +*~ + +# Sulkta — never commit secrets +*.mnemonic +*.key +*.age +.env +.env.* +data/ diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..96eb253 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,70 @@ +# Cargo workspace root for sulkta-wallet. +# +# Three crates: +# wallet-core — key derivation, signing, types, mnemonic handling +# wallet-chain — pluggable chain backends (Koios, Ogmios). Trait-first. +# wallet-mcp — binary; the MCP server, glues core+chain together. +# +# Workspace deps are pinned here so all three crates use the same versions. +# Add a dep here, then reference it in each crate's Cargo.toml as +# foo = { workspace = true } +[workspace] +resolver = "2" +members = [ + "crates/wallet-core", + "crates/wallet-chain", + "crates/wallet-mcp", +] + +[workspace.package] +version = "0.0.1" +edition = "2021" +license-file = "LICENSE" +repository = "http://192.168.0.5:3001/Sulkta-Coop/sulkta-wallet" +authors = ["Cobb ", "Kayos "] + +[workspace.dependencies] +# Async runtime — almost everything we do is I/O bound (chain queries, MCP stdio) +tokio = { version = "1", features = ["full"] } + +# Cardano stack — pallas is the rust-native primitives library by txpipe. +# We pull individual crates rather than the meta-crate so we control feature flags. +pallas-primitives = "0.32" +pallas-codec = "0.32" +pallas-crypto = "0.32" +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. +bip39 = "2" + +# 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 +# then — modern, audited, FOSS, and the secret never has to round-trip +# through a daemon password prompt. +age = "0.10" + +# Memory hygiene — wipe key material from RAM when keys go out of scope. +zeroize = { version = "1", features = ["derive"] } + +# Errors — anyhow at the boundaries (binary), thiserror for crate-internal types +anyhow = "1" +thiserror = "1" + +# Serde for everything JSON +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# HTTP client for Koios + future Ogmios HTTP endpoints +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } + +# MCP SDK for Rust. Note: the official Rust SDK has been moving fast +# (modelcontextprotocol/rust-sdk on github). Pin a version once we +# verify the API shape we actually use. +rmcp = { version = "0.1", features = ["server", "transport-io"] } + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e4e666c --- /dev/null +++ b/LICENSE @@ -0,0 +1,11 @@ +Proprietary — Sulkta Coop internal. + +Copyright (c) 2026 Sulkta Coop. + +This source code is the exclusive property of Sulkta Coop and is +distributed only to its members + collaborators. No public +redistribution or reuse without written permission. + +Sulkta Coop reserves the right to relicense this work under an +open-source license (MIT, Apache-2.0, or AGPL-3.0) at any time. If a +public release happens, the new license terms will replace this file. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bdf8588 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# sulkta-wallet + +Rust-native Cardano lite wallet with an MCP-server interface — built +for LLM-first usage (send, receive, mint, Plutus interaction). + +> **Status: Phase 1 scaffold (2026-05-04).** Compiles, structure in +> place, real wallet primitives still landing. See `ROADMAP.md`. + +## Why + +The existing Cardano MCP servers are either read-only doc gateways +([Jimmyh-world/Cardano_MCP](https://github.com/Jimmyh-world/Cardano_MCP)) +or built on Blockfrost ([web3-mcp](https://github.com/strangelove-ventures/web3-mcp)) +which is a centralized API we deliberately don't depend on. Sulkta +runs its own Koios + Ogmios endpoints on Rackham; we want a wallet +that talks directly to those. + +Also: it's the first Sulkta Rust project — useful as a workout for +crafting-table's Rust toolchain (per `Sulkta-Coop/lucy-infra` +`spec-crafting-table.md`). + +## Architecture + +Three crates in a Cargo workspace: + +| Crate | Responsibility | +|---|---| +| `wallet-core` | Pure crypto + types. Mnemonic → root key (CIP-3), root → payment + stake key (CIP-1852), address construction, signing. **No I/O, no network.** This is the security boundary. | +| `wallet-chain` | Pluggable backends for chain queries. `ChainBackend` trait, with Koios as the phase-1 implementation. Ogmios + submission paths in phase 2. | +| `wallet-mcp` | Binary. MCP server speaking stdio. Glues core + chain together, exposes tools to the LLM client. | + +``` + ┌─────────────────────────────┐ +LLM client │ wallet-mcp (bin) │ stdio +─────────► │ tool handlers, lifecycle │ ────► + └──────────┬──────────────────┘ + │ + ┌────────┴────────┐ + ▼ ▼ + ┌──────────────┐ ┌──────────────┐ + │ wallet-core │ │ wallet-chain │ + │ keys, sign │ │ Koios/Ogmios │ + └──────────────┘ └──────────────┘ +``` + +## MCP tools (target) + +Phase 1: +- `wallet.address` — derived base address at account 0, index 0 +- `wallet.balance` — ADA + native asset balance at the wallet's address +- `wallet.utxos` — list UTXOs + +Phase 2: +- `wallet.send` — build, sign, submit a payment (ADA or native) +- `wallet.tx_status` — poll a submitted tx hash + +Phase 3: +- `wallet.mint` — mint a CIP-25 / CIP-68 native asset +- `wallet.policy.create` — generate a policy script (timelock, multisig) + +Phase 4: +- `wallet.script.attach` — attach an inline datum + reference script +- `wallet.script.spend` — spend a Plutus-locked UTXO with redeemer +- `wallet.stake.delegate` — delegate to a pool + +## Build + +```bash +# Local (requires rustc 1.75+) +cargo build --release + +# Through crafting-table (preferred — validates the toolchain there) +crafting-table build sulkta-wallet +``` + +## Run + +```bash +# Direct invocation (smoke test only — does nothing useful in phase 1) +./target/release/sulkta-wallet + +# As an MCP server registered with Claude Code: +# add to ~/.claude.json: +# "sulkta-wallet": { +# "command": "/path/to/sulkta-wallet", +# "env": { +# "SULKTA_WALLET_DATA": "/mnt/cache/appdata/sulkta-wallet" +# } +# } +``` + +## Security model + +- **Mnemonic source:** interactive bootstrap on first run, paste once, encrypted at + rest with [age](https://github.com/FiloSottile/age). Never written to disk in + plaintext. +- **Derived keys:** in-memory only, `ZeroizeOnDrop` on every container. +- **Network exposure:** stdio MCP transport — never opens a TCP socket. + Only the spawning client process can reach it. +- **Multi-network:** mainnet by default, but `--network preview` / + `--network preprod` for testing without real ADA. + +## See also + +- `ROADMAP.md` — phased buildout +- `docs/architecture.md` — deeper design notes +- [txpipe/pallas](https://github.com/txpipe/pallas) — the Rust Cardano + building blocks we depend on +- [Emurgo/cardano-serialization-lib](https://github.com/Emurgo/cardano-serialization-lib) — + reference TX builder if pallas-txbuilder doesn't cover something +- [modelcontextprotocol/rust-sdk](https://github.com/modelcontextprotocol/rust-sdk) — + rmcp, the Rust MCP server SDK we use diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..47bee79 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,97 @@ +# sulkta-wallet roadmap + +Phased buildout. Each phase ships a usable increment + leaves the +codebase in a state where Phase N+1 picks up cleanly. + +## Phase 1 — MVP read path (current scaffold) + +**Goal:** address + balance + UTXOs from a real mnemonic, working +end-to-end through the MCP transport. + +- [x] Cargo workspace +- [x] Crate skeletons: `wallet-core`, `wallet-chain`, `wallet-mcp` +- [x] Type stubs + ZeroizeOnDrop scaffolding for keys +- [ ] `wallet-core::Mnemonic::into_root_key` — real CIP-3 derivation via `pallas-crypto` +- [ ] `wallet-core::derive_base_address` — real CIP-1852 + bech32 +- [ ] `wallet-chain::KoiosClient::get_utxos` — real `reqwest` to `/address_utxos` +- [ ] `wallet-chain::KoiosClient::get_balance` +- [ ] Interactive mnemonic bootstrap CLI: paste once, age-encrypt to disk +- [ ] On-startup decryption — single passphrase prompt, derived key in + RAM only +- [ ] Wire MCP server (rmcp) — register `wallet.address`, + `wallet.balance`, `wallet.utxos` tools +- [ ] Smoke test against testnet (preprod) + +**Done = `claude` can invoke `wallet.address` and get the right +preprod address back; `wallet.balance` returns matching numbers from +a Koios query.** + +## Phase 2 — write path (send) + +**Goal:** the wallet can spend. + +- [ ] `wallet-chain::ChainBackend::submit_tx` — POST CBOR to Koios `/submittx` +- [ ] `wallet-chain::tx_status` — poll `/tx_info` +- [ ] Build + sign ADA-only payment via `pallas-txbuilder` +- [ ] MCP tool `wallet.send` with `to_address`, `lovelace` args +- [ ] MCP tool `wallet.tx_status` with `tx_hash` arg +- [ ] Add native-asset send (multi-asset value bundle) +- [ ] Add `wallet.send.sign_only` for offline / multisig flows +- [ ] Hard guard: reject outbound TXs over $X equivalent unless flag set + (preventable LLM mistake) + +**Done = the wallet successfully sends 1 tADA on preprod, then 1 ADA +on mainnet to a known test address, both initiated via an MCP tool +call from Claude Code.** + +## Phase 3 — minting + +**Goal:** wallet can mint Sulkta native assets. + +- [ ] Policy script construction — pure-timelock + multisig variants +- [ ] CIP-25 metadata serialization (legacy 721 metadatum) +- [ ] CIP-68 ref-NFT pattern (300/100/333 standards) +- [ ] MCP tool `wallet.policy.create` — returns `policy_id` + serialized script +- [ ] MCP tool `wallet.mint` — args: `policy`, `assets`, `metadata` +- [ ] Integration with the MAP treasury minting pattern (2-of-2 multisig) + +**Done = the wallet can mint a test asset on preprod with both CIP-25 +and CIP-68 metadata, queryable via Koios `/asset_info`.** + +## Phase 4 — Plutus interaction + +**Goal:** consume Plutus-locked UTXOs, attach reference scripts, delegate stake. + +- [ ] Inline datum support +- [ ] Reference input attachment +- [ ] `wallet.script.spend` — args: `utxo_ref`, `redeemer_cbor`, + `script_cbor` or reference, `additional_signers` +- [ ] Script execution unit estimation (call out to a local cardano-cli + or a reasonable approximation) +- [ ] Stake key derivation (chain index 2) +- [ ] `wallet.stake.delegate` — args: `pool_id`, optional drep_id (Voltaire era) +- [ ] Drep voting tools if Cobb cares (separate ask) + +**Done = the wallet successfully spends a UTXO locked by a trivial +Plutus validator (e.g. "always succeeds") on preprod.** + +## Out-of-scope (deliberately) + +- **Hot-key signing for high-value mainnet** — for any tx over a + per-config threshold, the wallet should write the unsigned TX to a + file and require a separate cold-signing flow (mirrors the + ADAMaps treasury pattern at `memory/MEMORY.md` ADAMaps section). +- **Smart contract deployment / Plutus compilation** — that's an Aiken + / plutus-tx job. This wallet only consumes pre-compiled scripts. +- **Browser / Web UI** — pure MCP-as-the-interface. Humans interact + via the LLM client. +- **Multiple wallets in one daemon** — instance-per-wallet by design. + Run multiple binaries if needed. + +## Performance / size targets (informal) + +- Cold-start time: < 200 ms (mnemonic decrypt + key derive) +- Per-tool latency: dominated by chain backend (Koios round-trip + ~50-200 ms); the wallet itself should add < 10 ms +- Binary size: < 30 MB stripped release +- Memory: < 50 MB RSS steady-state diff --git a/crates/wallet-chain/Cargo.toml b/crates/wallet-chain/Cargo.toml new file mode 100644 index 0000000..1f29241 --- /dev/null +++ b/crates/wallet-chain/Cargo.toml @@ -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" diff --git a/crates/wallet-chain/src/lib.rs b/crates/wallet-chain/src/lib.rs new file mode 100644 index 0000000..e458596 --- /dev/null +++ b/crates/wallet-chain/src/lib.rs @@ -0,0 +1,97 @@ +//! sulkta-wallet 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, +} + +/// Aggregated balance at an address. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Balance { + pub lovelace: u64, + pub assets: std::collections::BTreeMap, +} + +#[async_trait::async_trait] +pub trait ChainBackend: Send + Sync { + async fn get_utxos(&self, address: &str) -> Result, ChainError>; + async fn get_balance(&self, address: &str) -> Result; + // Phase 2: + // async fn submit_tx(&self, raw_tx_cbor: &[u8]) -> Result; + // async fn tx_status(&self, tx_hash: &str) -> Result; +} + +/// 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) -> Self { + Self { + base_url: base_url.into(), + } + } +} + +#[async_trait::async_trait] +impl ChainBackend for KoiosClient { + async fn get_utxos(&self, _address: &str) -> Result, ChainError> { + // TODO(phase 1): POST /address_utxos with {"_addresses": [
]} + Ok(vec![]) + } + + async fn get_balance(&self, _address: &str) -> Result { + // 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); + } +} diff --git a/crates/wallet-core/Cargo.toml b/crates/wallet-core/Cargo.toml new file mode 100644 index 0000000..420a014 --- /dev/null +++ b/crates/wallet-core/Cargo.toml @@ -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 } diff --git a/crates/wallet-core/src/lib.rs b/crates/wallet-core/src/lib.rs new file mode 100644 index 0000000..dd2e087 --- /dev/null +++ b/crates/wallet-core/src/lib.rs @@ -0,0 +1,142 @@ +//! sulkta-wallet 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 { + // 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 { + // 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 { + // 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")); + } +} diff --git a/crates/wallet-mcp/Cargo.toml b/crates/wallet-mcp/Cargo.toml new file mode 100644 index 0000000..39358ef --- /dev/null +++ b/crates/wallet-mcp/Cargo.toml @@ -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 (Claude Code, OpenClaw, etc.). +# +# 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 = "sulkta-wallet" +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 } diff --git a/crates/wallet-mcp/src/main.rs b/crates/wallet-mcp/src/main.rs new file mode 100644 index 0000000..129d747 --- /dev/null +++ b/crates/wallet-mcp/src/main.rs @@ -0,0 +1,74 @@ +//! sulkta-wallet — MCP server entry point. +//! +//! 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 +//! +//! - `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!("sulkta-wallet 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 { + // 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") +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..f089e9b --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,123 @@ +# sulkta-wallet architecture notes + +Deeper design notes than the README. Read this before extending. + +## Crate boundaries — and why + +The three-crate split exists to keep the security-sensitive code +auditable in isolation. + +- `wallet-core` — **no I/O.** Given a mnemonic + a derivation path, + produces keys + addresses + signatures. Deterministic, no + dependencies on tokio, reqwest, MCP, or anything that could + introduce side channels. Easy to audit because it's narrow. +- `wallet-chain` — **all the I/O lives here.** Trait-first so the MCP + layer never knows whether it's talking to Koios, Ogmios, or a future + backend. Future contributors swap implementations without touching + the security-sensitive crate. +- `wallet-mcp` — **the binary glue.** Owns process lifecycle, config + loading, MCP transport, tool registration, error mapping. The + thinnest layer. + +This is the same pattern PetalParse + Cauldron use with their +`-core` / `-web` split. Consistent across Sulkta +codebases. + +## Threat model + +The wallet is single-user, single-machine, behind an MCP transport +that's only reachable by the spawning process. Threats we care about, +roughly in order: + +1. **LLM mistake.** The most likely threat: the LLM (me, future-me, + or an agent) constructs a wrong transaction and asks the wallet + to sign it. Mitigations: hard caps on outbound value (config), TX + review tool that returns a human-readable summary before signing, + `--dry-run` flag for any state-changing tool. +2. **Daemon process compromise.** If the wallet binary is exploited + (e.g. via a malformed Koios response triggering memory corruption), + the keys are at risk. Mitigations: keep `wallet-core` narrow + (smaller attack surface), zeroize on drop, future: drop + privileges + seccomp the daemon. +3. **Disk read.** The encrypted mnemonic on disk could be exfiltrated. + Mitigations: age encryption (audited modern primitive), passphrase + never persisted, separate disk path from the daemon's runtime + data. +4. **Memory dump / swap.** Live key material in RAM could leak via + swap, hibernate state, or a core dump. Mitigations: zeroize on + drop, no swap on Lucy (Cobb confirms), future: `mlock` the + key-holding pages. +5. **Network.** No exposure — stdio MCP transport only. If we ever + add a TCP listener that's a separate threat-modeling exercise. + +## Mnemonic lifecycle + +``` +First run: + user pastes mnemonic at interactive prompt + ↓ + wallet-mcp asks for an encryption passphrase + ↓ + age-encrypt the mnemonic phrase + ↓ + write to $SULKTA_WALLET_DATA/mnemonic.age + ↓ + derive RootKey, hold in RAM, zeroize the source phrase + ↓ + daemon ready + +Subsequent runs: + read $SULKTA_WALLET_DATA/mnemonic.age + ↓ + prompt for passphrase + ↓ + age-decrypt → ephemeral String + ↓ + derive RootKey, immediately zeroize the decrypted phrase + ↓ + daemon ready +``` + +The decrypted phrase exists in RAM only between age-decrypt and +RootKey derivation — measured in milliseconds. + +## Why pallas over cardano-serialization-lib + +Pallas is rust-native and modular. cardano-serialization-lib is +rust-with-WASM-as-the-primary-target — its API shape reflects the +JS ecosystem more than the Rust ecosystem (Result-as-Option-as-error, +ToString-heavy, Box at the boundary). Pallas reads more +idiomatic. + +That said: if pallas-txbuilder is ever missing something we need, fall +back to cardano-serialization-lib via the `@emurgo/cardano-serialization-lib` +Rust crate. It's the canonical TX builder, used by Yoroi. + +## Why rmcp over rolling our own MCP server + +The MCP wire protocol is JSON-RPC 2.0 with specific lifecycle messages +(`initialize`, `tools/list`, `tools/call`, etc.). It's possible to +hand-roll, but rmcp handles the boilerplate, the param schema +generation from Rust types, and the stdio framing. Standard SDK +choice for any Rust MCP server. + +If rmcp turns out to be unstable / too slow to compile / API churn, +the fallback is to write the JSON-RPC 2.0 handlers directly with +serde — a few hundred lines. + +## Future: hot vs cold signing split + +For mainnet operations over a configurable lovelace threshold, the +phase-2 design is: + +1. `wallet.send` validates the tx, builds it, but **doesn't sign**. +2. Returns the unsigned CBOR + a one-line human summary ("send 100 + ADA to addr1xyz, fee 0.17 ADA, expected balance after: …"). +3. LLM relays the summary to Cobb, gets approval. +4. Cobb runs a separate `sulkta-wallet-cold-sign` CLI on a different + box (offline laptop, cardano-signer, whatever) — paste the CBOR, + approve, paste back the signed CBOR. +5. `wallet.submit_signed_tx` takes the signed CBOR + submits. + +This mirrors the ADAMaps MAP treasury cold-signing pattern. Avoids +mainnet auto-sign by an LLM agent.