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:
commit
489b58cc1e
12 changed files with 832 additions and 0 deletions
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal 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
70
Cargo.toml
Normal 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
11
LICENSE
Normal 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
112
README.md
Normal 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
97
ROADMAP.md
Normal 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
|
||||||
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 @@
|
||||||
|
//! 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
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 @@
|
||||||
|
//! 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
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 (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 }
|
||||||
74
crates/wallet-mcp/src/main.rs
Normal file
74
crates/wallet-mcp/src/main.rs
Normal 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
123
docs/architecture.md
Normal 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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue