//! 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` //! //! A self-hosted Koios deployment drops in transparently — same API, //! whatever URL the operator points at. 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, TxStatus, 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>, } /// Same as [`AddressesBody`] but with the `_extended` flag set. /// Without `_extended`, Koios's `/address_utxos` returns /// `asset_list: null` (or empty), causing asset-bearing UTXOs to /// look ada-only — multi-asset sends then fail to build. #[derive(Serialize)] struct AddressesExtendedBody<'a> { #[serde(rename = "_addresses")] addresses: Vec<&'a str>, #[serde(rename = "_extended")] extended: bool, } #[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, /// `Option>` because Koios's `/address_utxos` returns /// `asset_list: null` for ADA-only UTXOs (vs `/address_info` /// which returns `[]`). `Vec` rejects `null`; `Option>` /// accepts both. #[serde(default)] asset_list: Option>, } #[derive(Deserialize)] struct KoiosAddressInfo { /// Total lovelace at this address. balance: String, #[serde(default)] utxo_set: Vec, } #[derive(Serialize)] struct TxHashesBody<'a> { #[serde(rename = "_tx_hashes")] tx_hashes: Vec<&'a str>, } /// Response shape from Koios `/api/v1/tx_status`. Tiny — only a /// confirmations counter per requested tx — vs `/tx_info` which /// streams the full tx body (multi-MB for complex confirmed txs). /// Prefer this for status polling to avoid the multi-second hang /// when fetching large confirmed-tx bodies. #[derive(Deserialize)] struct KoiosTxStatusResp { #[allow(dead_code)] tx_hash: String, #[serde(default)] num_confirmations: Option, } pub struct KoiosClient { base_url: String, http: Client, } impl KoiosClient { /// Construct a client with the default 10-second timeout and no /// bearer (public-tier; subject to free-tier daily quotas). pub fn new(base_url: impl Into) -> Self { Self::with_timeout_and_bearer(base_url, DEFAULT_TIMEOUT, None) } /// Construct a client with a custom request timeout, no bearer. pub fn with_timeout(base_url: impl Into, timeout: Duration) -> Self { Self::with_timeout_and_bearer(base_url, timeout, None) } /// Construct a client with optional `Authorization: Bearer ` /// applied to every request. Used for paid-tier Koios access — the /// JWT comes from the operator-supplied `ALDABRA_KOIOS_BEARER` env /// var (NEVER from the on-disk config, NEVER hardcoded). Pass /// `None` for the free public tier. pub fn with_timeout_and_bearer( base_url: impl Into, timeout: Duration, bearer: Option<&str>, ) -> Self { let mut builder = Client::builder().timeout(timeout); if let Some(token) = bearer { // Default header is applied to every request the client // emits — request-level overrides still possible but no // builder code path needs to remember to set it. let mut hdrs = reqwest::header::HeaderMap::new(); let value = format!("Bearer {token}"); let mut hv = reqwest::header::HeaderValue::from_str(&value) .expect("ALDABRA_KOIOS_BEARER contains invalid header bytes"); hv.set_sensitive(true); hdrs.insert(reqwest::header::AUTHORIZATION, hv); builder = builder.default_headers(hdrs); } Self { base_url: base_url.into(), http: builder .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(&self, path: &str, body: &T) -> Result 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::() .await .map_err(|e| ChainError::Decode(e.to_string())) } /// Generic POST that returns the raw JSON response as a `String` — /// for the `chain_*` MCP passthrough tools where we don't want to /// re-shape Koios's response into typed Rust structures. Caller /// passes a serializable body (often `serde_json::json!({...})`) /// and gets back the response body verbatim. pub async fn post_raw_json( &self, path: &str, body: &T, ) -> Result { 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()))? .text() .await .map_err(|e| ChainError::Decode(e.to_string())) } /// Generic GET (with optional query string) that returns the raw /// JSON response as a `String`. Used for Koios endpoints that /// take filters as query params (`pool_list?ticker=eq.AHL`, /// `epoch_params`, `tip`, etc.). pub async fn get_raw_json( &self, path: &str, query: &[(&str, &str)], ) -> Result { self.http .get(self.url(path)) .query(query) .send() .await .map_err(|e| ChainError::Network(e.to_string()))? .error_for_status() .map_err(|e| ChainError::Network(e.to_string()))? .text() .await .map_err(|e| ChainError::Decode(e.to_string())) } } fn parse_u64(s: &str, field: &str) -> Result { s.parse::() .map_err(|e| ChainError::Decode(format!("{field}: {e} (got {s:?})"))) } /// True iff `s` is exactly 64 hex chars — what a Cardano tx hash must /// look like. Used by `submit_tx` to validate the response wasn't an /// error message wrapped in quotes. fn is_hex_64(s: &str) -> bool { s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit()) } 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 { let lovelace = parse_u64(&k.value, "utxo.value")?; let mut assets = BTreeMap::new(); for a in k.asset_list.unwrap_or_default() { 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, ChainError> { // `_extended: true` is required for the per-utxo asset_list // to populate. Without it Koios returns `asset_list: null` or // `[]` even when the utxo carries native assets, which makes // the wallet's selection algorithm think it has zero of any // token. let body = AddressesExtendedBody { addresses: vec![address], extended: true, }; let raw: Vec = self.post_json("address_utxos", &body).await?; raw.into_iter().map(convert_utxo).collect() } async fn get_balance(&self, address: &str) -> Result { let body = AddressesBody { addresses: vec![address], }; let raw: Vec = 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 = BTreeMap::new(); for u in info.utxo_set { for a in u.asset_list.unwrap_or_default() { 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 }) } async fn submit_tx(&self, raw_tx_cbor: &[u8]) -> Result { // /submittx is special: body is raw CBOR bytes, not JSON. // Returns the tx hash as plain text on success. let response = self .http .post(self.url("submittx")) .header(reqwest::header::CONTENT_TYPE, "application/cbor") .body(raw_tx_cbor.to_vec()) .send() .await .map_err(|e| ChainError::Network(e.to_string()))?; // Capture status + body BEFORE bubbling up — Koios's chain-rule // rejection messages live in the response body and are otherwise // eaten by `.error_for_status()`, leaving callers with no signal // beyond an HTTP 400. let status = response.status(); let body = response .text() .await .map_err(|e| ChainError::Decode(e.to_string()))?; if !status.is_success() { return Err(ChainError::Network(format!( "submittx HTTP {}: {}", status.as_u16(), body.trim() ))); } // Koios returns the tx hash as a quoted JSON string. Strip the // surrounding quotes if present, then validate the result is // exactly 64 hex chars — guards against a quoted error message // round-tripping as a fake tx_hash. let hash = body.trim().trim_matches('"').to_string(); if !is_hex_64(&hash) { return Err(ChainError::Decode(format!( "submittx returned non-hash response: {body:?}" ))); } Ok(hash) } async fn tx_status(&self, tx_hash: &str) -> Result { let body = TxHashesBody { tx_hashes: vec![tx_hash], }; let raw: Vec = self.post_json("tx_status", &body).await?; match raw.into_iter().next() { Some(info) => match info.num_confirmations { Some(n) if n > 0 => Ok(TxStatus::Confirmed { num_confirmations: n, }), Some(_) | None => Ok(TxStatus::Pending), }, None => Ok(TxStatus::NotFound), } } } #[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" } ] } ] } ]"#; /// Real Koios `/address_utxos` returns `asset_list: null` for /// ada-only utxos (vs `/address_info` which returns `[]`). /// Regression test for the null-vs-empty-array deserialisation. #[test] fn deserializes_utxo_with_null_asset_list() { const SAMPLE: &str = r#"[ { "tx_hash": "c22c9ccc165091819673101e8e49e7daed559fad838bbf08fd8e5b9305cf1e60", "tx_index": 0, "value": "10000000000", "asset_list": null } ]"#; let raw: Vec = serde_json::from_str(SAMPLE).unwrap(); let utxos: Vec = raw .into_iter() .map(convert_utxo) .collect::>() .unwrap(); assert_eq!(utxos.len(), 1); assert_eq!(utxos[0].lovelace, 10_000_000_000); assert!(utxos[0].assets.is_empty()); } #[test] fn deserializes_utxo_response() { let raw: Vec = serde_json::from_str(SAMPLE_UTXOS).unwrap(); let utxos: Vec = raw .into_iter() .map(convert_utxo) .collect::>() .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 = 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 = serde_json::from_str(TWO_UTXOS_SAME_ASSET).unwrap(); let info = raw.into_iter().next().unwrap(); let mut assets: BTreeMap = BTreeMap::new(); for u in info.utxo_set { for a in u.asset_list.unwrap_or_default() { 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 is_hex_64_validates_tx_hash_shape() { assert!(is_hex_64(&"a".repeat(64))); assert!(is_hex_64(&"ABCDef0123456789".repeat(4))); assert!(!is_hex_64(&"a".repeat(63)), "wrong length"); assert!(!is_hex_64(&"a".repeat(65)), "wrong length"); assert!(!is_hex_64(&"z".repeat(64)), "non-hex chars"); assert!(!is_hex_64("invalid tx"), "error message"); } #[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" ); } // tx_info-shape tests were dropped when tx_status moved from // /tx_info to /tx_status. Coverage is in parses_koios_tx_status_shapes // below. #[test] fn tx_status_serializes_with_tag() { let confirmed = TxStatus::Confirmed { num_confirmations: 17, }; let json = serde_json::to_string(&confirmed).unwrap(); assert!(json.contains("\"status\":\"confirmed\"")); assert!(json.contains("\"num_confirmations\":17")); let pending = TxStatus::Pending; let json = serde_json::to_string(&pending).unwrap(); assert!(json.contains("\"status\":\"pending\"")); let nf = TxStatus::NotFound; let json = serde_json::to_string(&nf).unwrap(); assert!(json.contains("\"status\":\"not_found\"")); } /// Regression: parse the three live Koios `/tx_status` shapes — /// confirmed-with-count, known-but-no-confs (mempool), and /// nothing-to-report (truly unknown). #[test] fn parses_koios_tx_status_shapes() { let confirmed = r#"[{"tx_hash":"abcd","num_confirmations":7}]"#; let v: Vec = serde_json::from_str(confirmed).unwrap(); assert_eq!(v[0].num_confirmations, Some(7)); let pending = r#"[{"tx_hash":"abcd","num_confirmations":null}]"#; let v: Vec = serde_json::from_str(pending).unwrap(); assert_eq!(v[0].num_confirmations, None); // Some Koios deployments omit num_confirmations entirely // for unknown txs rather than emitting null. let omitted = r#"[{"tx_hash":"abcd"}]"#; let v: Vec = serde_json::from_str(omitted).unwrap(); assert_eq!(v[0].num_confirmations, None); let empty = r#"[]"#; let v: Vec = serde_json::from_str(empty).unwrap(); assert!(v.is_empty()); } /// 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() ); } }