phase 1: full read path — bip39 + cip-3 + cip-1852 + koios + age-mnemonic + rmcp

end-to-end working wallet: paste 24-word mnemonic, age-encrypt at rest,
on unlock derive root + payment + stake keys, build cip-19 base address,
serve four tools over mcp stdio (wallet.address, wallet.network,
wallet.balance, wallet.utxos).

deps added: ed25519-bip32 0.4 (pallas only ships raw ed25519, not the
cardano variant of bip32 hd derivation), cryptoxide 0.4 for pbkdf2-hmac-sha512,
age 0.10 for at-rest mnemonic encryption, rpassword 7 for tty-only passphrase
prompts, toml 0.9 for config.toml.

new modules:
- crates/aldabra-core/src/derive.rs — payment + stake key derivation, hash
- crates/aldabra-chain/src/koios.rs — real reqwest impl, asset aggregation
- crates/aldabra-mcp/src/{bootstrap,config,tools}.rs

caught one bug pre-flight: get_balance was clobbering same-asset
quantities across utxos instead of summing. fixed + regression test.

headless support via ALDABRA_PASSPHRASE env (mcp clients own stdin so
the rpassword prompt path can't run). docker secret / systemd
EnvironmentFile sources it in production.

dockerfile: multi-stage rust:1.95-bookworm → debian:bookworm-slim, tini
as pid1, non-root aldabra user, /var/lib/aldabra owned 700.

29 unit tests + 1 ignored live-koios test. preprod smoke test exercised
initialize → tools/list → tools/call wallet.address end-to-end via
piped json-rpc; correct preprod address came back from canonical
abandon-art mnemonic.

phase 2 (send) is next.
This commit is contained in:
Sulkta 2026-05-04 11:09:00 -07:00
parent edc976e5d9
commit 2b4fdff0c0
14 changed files with 4389 additions and 167 deletions

View file

@ -0,0 +1,328 @@
//! Koios REST client — POST queries against the Cardano node feature
//! set Koios provides on top of cardano-db-sync.
//!
//! All Koios POST endpoints take a body of `{"_addresses": [...]}`
//! plus optional flags. We use `/address_utxos` for the UTXO set and
//! `/address_info` for the aggregate balance + nested UTXO snapshot.
//!
//! Numeric quantities come back as strings — Cardano amounts are
//! uint64s and JSON-as-spec doesn't safely round-trip those through
//! a JS Number. We parse them into `u64` here.
//!
//! ## Endpoint URLs
//!
//! - mainnet: `https://api.koios.rest/api/v1`
//! - preprod: `https://preprod.koios.rest/api/v1`
//! - preview: `https://preview.koios.rest/api/v1`
//!
//! Sulkta-hosted Koios on dedicated servers (when it lands) drops in here too —
//! it's the same API.
use std::collections::BTreeMap;
use std::time::Duration;
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use crate::{Balance, ChainBackend, ChainError, Utxo};
/// Default timeout for a single Koios HTTP call. 10 s covers the
/// public mainnet endpoint's worst case.
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Serialize)]
struct AddressesBody<'a> {
#[serde(rename = "_addresses")]
addresses: Vec<&'a str>,
}
#[derive(Deserialize)]
struct KoiosAsset {
policy_id: String,
/// Hex-encoded asset name (NOT bech32 fingerprint).
asset_name: String,
/// uint64 wrapped in a string per Koios conventions.
quantity: String,
}
#[derive(Deserialize)]
struct KoiosUtxo {
tx_hash: String,
tx_index: u32,
/// Lovelace at this UTXO, uint64 in a string.
value: String,
#[serde(default)]
asset_list: Vec<KoiosAsset>,
}
#[derive(Deserialize)]
struct KoiosAddressInfo {
/// Total lovelace at this address.
balance: String,
#[serde(default)]
utxo_set: Vec<KoiosUtxo>,
}
pub struct KoiosClient {
base_url: String,
http: Client,
}
impl KoiosClient {
/// Construct a client with the default 10-second timeout.
pub fn new(base_url: impl Into<String>) -> Self {
Self::with_timeout(base_url, DEFAULT_TIMEOUT)
}
/// Construct a client with a custom request timeout.
pub fn with_timeout(base_url: impl Into<String>, timeout: Duration) -> Self {
Self {
base_url: base_url.into(),
http: Client::builder()
.timeout(timeout)
.build()
.expect("reqwest client builds with rustls + json features"),
}
}
fn url(&self, path: &str) -> String {
format!("{}/{}", self.base_url.trim_end_matches('/'), path)
}
async fn post_json<T, R>(&self, path: &str, body: &T) -> Result<R, ChainError>
where
T: Serialize,
R: for<'de> Deserialize<'de>,
{
self.http
.post(self.url(path))
.json(body)
.send()
.await
.map_err(|e| ChainError::Network(e.to_string()))?
.error_for_status()
.map_err(|e| ChainError::Network(e.to_string()))?
.json::<R>()
.await
.map_err(|e| ChainError::Decode(e.to_string()))
}
}
fn parse_u64(s: &str, field: &str) -> Result<u64, ChainError> {
s.parse::<u64>()
.map_err(|e| ChainError::Decode(format!("{field}: {e} (got {s:?})")))
}
fn asset_key(policy_id: &str, asset_name_hex: &str) -> String {
let mut k = String::with_capacity(policy_id.len() + asset_name_hex.len());
k.push_str(policy_id);
k.push_str(asset_name_hex);
k
}
fn convert_utxo(k: KoiosUtxo) -> Result<Utxo, ChainError> {
let lovelace = parse_u64(&k.value, "utxo.value")?;
let mut assets = BTreeMap::new();
for a in k.asset_list {
let qty = parse_u64(&a.quantity, "utxo.asset.quantity")?;
assets.insert(asset_key(&a.policy_id, &a.asset_name), qty);
}
Ok(Utxo {
tx_hash: k.tx_hash,
output_index: k.tx_index,
lovelace,
assets,
})
}
#[async_trait]
impl ChainBackend for KoiosClient {
async fn get_utxos(&self, address: &str) -> Result<Vec<Utxo>, ChainError> {
let body = AddressesBody { addresses: vec![address] };
let raw: Vec<KoiosUtxo> = self.post_json("address_utxos", &body).await?;
raw.into_iter().map(convert_utxo).collect()
}
async fn get_balance(&self, address: &str) -> Result<Balance, ChainError> {
let body = AddressesBody { addresses: vec![address] };
let raw: Vec<KoiosAddressInfo> = self.post_json("address_info", &body).await?;
// Empty array = address has no on-chain history yet — treat
// as a zero balance rather than an error. Match Koios's own
// semantics.
let Some(info) = raw.into_iter().next() else {
return Ok(Balance {
lovelace: 0,
assets: BTreeMap::new(),
});
};
let lovelace = parse_u64(&info.balance, "address_info.balance")?;
let mut assets: BTreeMap<String, u64> = BTreeMap::new();
for u in info.utxo_set {
for a in u.asset_list {
let qty = parse_u64(&a.quantity, "address_info.utxo.asset.quantity")?;
let key = asset_key(&a.policy_id, &a.asset_name);
let entry = assets.entry(key).or_insert(0);
*entry = entry.saturating_add(qty);
}
}
Ok(Balance { lovelace, assets })
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Hand-crafted Koios `/address_utxos` response shape — verifies
/// our deserialize path without hitting the network.
const SAMPLE_UTXOS: &str = r#"[
{
"tx_hash": "1a2b3c4d5e6f00000000000000000000000000000000000000000000000000aa",
"tx_index": 0,
"value": "1500000",
"asset_list": []
},
{
"tx_hash": "1a2b3c4d5e6f00000000000000000000000000000000000000000000000000bb",
"tx_index": 1,
"value": "10000000",
"asset_list": [
{
"policy_id": "ee0a1234",
"asset_name": "deadbeef",
"quantity": "42"
}
]
}
]"#;
const SAMPLE_ADDRESS_INFO: &str = r#"[
{
"address": "addr1...",
"balance": "11500000",
"stake_address": "stake1...",
"script_address": false,
"utxo_set": [
{
"tx_hash": "1a2b3c4d5e6f00000000000000000000000000000000000000000000000000aa",
"tx_index": 0,
"value": "1500000",
"asset_list": []
},
{
"tx_hash": "1a2b3c4d5e6f00000000000000000000000000000000000000000000000000bb",
"tx_index": 1,
"value": "10000000",
"asset_list": [
{
"policy_id": "ee0a1234",
"asset_name": "deadbeef",
"quantity": "42"
}
]
}
]
}
]"#;
#[test]
fn deserializes_utxo_response() {
let raw: Vec<KoiosUtxo> = serde_json::from_str(SAMPLE_UTXOS).unwrap();
let utxos: Vec<Utxo> = raw.into_iter().map(convert_utxo).collect::<Result<_, _>>().unwrap();
assert_eq!(utxos.len(), 2);
assert_eq!(utxos[0].lovelace, 1_500_000);
assert!(utxos[0].assets.is_empty());
assert_eq!(utxos[1].lovelace, 10_000_000);
assert_eq!(utxos[1].assets.get("ee0a1234deadbeef"), Some(&42));
}
#[test]
fn deserializes_address_info_response() {
let raw: Vec<KoiosAddressInfo> = serde_json::from_str(SAMPLE_ADDRESS_INFO).unwrap();
assert_eq!(raw.len(), 1);
assert_eq!(raw[0].balance, "11500000");
assert_eq!(raw[0].utxo_set.len(), 2);
}
/// Two UTXOs holding the same asset must aggregate into a single
/// balance entry — protects against the get/insert bug where the
/// running total gets clobbered.
#[test]
fn balance_aggregates_same_asset_across_utxos() {
const TWO_UTXOS_SAME_ASSET: &str = r#"[
{
"address": "addr1...",
"balance": "20000000",
"stake_address": null,
"script_address": false,
"utxo_set": [
{
"tx_hash": "00aa",
"tx_index": 0,
"value": "10000000",
"asset_list": [
{"policy_id": "ee0a1234", "asset_name": "deadbeef", "quantity": "100"}
]
},
{
"tx_hash": "00bb",
"tx_index": 1,
"value": "10000000",
"asset_list": [
{"policy_id": "ee0a1234", "asset_name": "deadbeef", "quantity": "23"}
]
}
]
}
]"#;
let raw: Vec<KoiosAddressInfo> = serde_json::from_str(TWO_UTXOS_SAME_ASSET).unwrap();
let info = raw.into_iter().next().unwrap();
let mut assets: BTreeMap<String, u64> = BTreeMap::new();
for u in info.utxo_set {
for a in u.asset_list {
let qty = parse_u64(&a.quantity, "test").unwrap();
let key = asset_key(&a.policy_id, &a.asset_name);
let entry = assets.entry(key).or_insert(0);
*entry = entry.saturating_add(qty);
}
}
assert_eq!(assets.get("ee0a1234deadbeef"), Some(&123));
}
#[test]
fn parse_u64_rejects_garbage() {
let err = parse_u64("not-a-number", "test").unwrap_err();
match err {
ChainError::Decode(msg) => assert!(msg.contains("test")),
other => panic!("expected Decode, got {other:?}"),
}
}
#[test]
fn url_helper_handles_trailing_slash() {
let c = KoiosClient::new("https://api.koios.rest/api/v1/");
assert_eq!(
c.url("address_info"),
"https://api.koios.rest/api/v1/address_info"
);
}
/// Live network test against the public Koios mainnet endpoint.
/// Marked `#[ignore]` so `cargo test` skips it; run with
/// `cargo test -- --ignored live_koios_round_trip` to exercise.
#[tokio::test]
#[ignore]
async fn live_koios_round_trip() {
// A well-known mainnet address with stable history (IOG
// genesis treasury — historical).
let known_addr = "addr1q9zd6lvqu63rynk3kmzv0aphukk23gn37vfaq8e5kpdg45fkfsdfh67aae3eag2u4d97n6sm5qzcfmsrcgujhppfvxasn0nwt7";
let client = KoiosClient::new("https://api.koios.rest/api/v1");
let result = client.get_balance(known_addr).await;
// We don't assert a specific balance — just that the
// request shape is valid and the response decodes.
assert!(result.is_ok(), "live balance call failed: {:?}", result.err());
}
}

View file

@ -1,15 +1,28 @@
//! aldabra chain backends — Koios first, Ogmios next.
//!
//! Trait-first design: the MCP server depends on `ChainBackend`, not on
//! a specific implementation. Swapping Koios → Ogmios is a config change.
//! 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.
//! Phase 1: read-only queries (`get_utxos`, `get_balance`) against
//! Koios over HTTPS.
//!
//! Phase 2 (TODO): submission paths — `submit_tx`, `tx_status`.
//!
//! ## Backends
//!
//! - [`koios::KoiosClient`] — Koios REST client (POST `/address_utxos`,
//! `/address_info`). Sulkta runs its own Koios on dedicated servers; the
//! public `https://api.koios.rest/api/v1` works as a fallback.
//! - Ogmios (TODO) — websocket client.
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub mod koios;
pub use koios::KoiosClient;
#[derive(Debug, Error)]
pub enum ChainError {
#[error("network error: {0}")]
@ -23,9 +36,9 @@ pub enum ChainError {
}
/// 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)]
/// `policy_id || asset_name_hex` → quantity for now. We'll model it
/// more strictly when minting lands in phase 3.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Utxo {
pub tx_hash: String,
pub output_index: u32,
@ -36,7 +49,7 @@ pub struct Utxo {
}
/// Aggregated balance at an address.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Balance {
pub lovelace: u64,
pub assets: std::collections::BTreeMap<String, u64>,
@ -50,48 +63,3 @@ pub trait ChainBackend: Send + Sync {
// 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);
}
}