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

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.
This commit is contained in:
Kayos 2026-05-04 10:02:32 -07:00
commit 489b58cc1e
12 changed files with 832 additions and 0 deletions

18
.gitignore vendored Normal file
View file

@ -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/

70
Cargo.toml Normal file
View file

@ -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 <cobb@sulkta.com>", "Kayos <kayos@sulkta.com>"]
[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"] }

11
LICENSE Normal file
View file

@ -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.

112
README.md Normal file
View file

@ -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

97
ROADMAP.md Normal file
View file

@ -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

View 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"

View file

@ -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<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);
}
}

View 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 }

View file

@ -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<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"));
}
}

View 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 (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 }

View file

@ -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<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")
}

123
docs/architecture.md Normal file
View file

@ -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
`<service>-core` / `<service>-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<dyn Error> 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.