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:
parent
edc976e5d9
commit
2b4fdff0c0
14 changed files with 4389 additions and 167 deletions
328
crates/aldabra-chain/src/koios.rs
Normal file
328
crates/aldabra-chain/src/koios.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue