aldabra/crates/aldabra-chain/src/koios.rs
Sulkta b69d891a2b chore: strip audit-ticket prefixes from code comments
Drops the ~60 ticket-prefix comments (CRIT-N, HIGH-N, MED-N, LOW-N,
L-N, M-N, AUDIT-N, PLUTUS-N, "audit fix (date):", "Phase N" labels,
"Adversarial-review fix:") that had accumulated in inline + doc
comments over several audit cycles. Where the surrounding prose
still carried useful WHY context it gets kept and tightened; where
the ticket WAS the comment it gets dropped entirely.

No logic, no renames, no behavior change. Audit history lives in
commit messages and the audits/ tree where it belongs — eternal
comments don't need to mirror it.

Net 138 LOC shorter. 253 tests pass, no new clippy or fmt warnings.
2026-05-12 14:42:13 -07:00

595 lines
21 KiB
Rust

//! 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<Vec<...>>` because Koios's `/address_utxos` returns
/// `asset_list: null` for ADA-only UTXOs (vs `/address_info`
/// which returns `[]`). `Vec<T>` rejects `null`; `Option<Vec<T>>`
/// accepts both.
#[serde(default)]
asset_list: Option<Vec<KoiosAsset>>,
}
#[derive(Deserialize)]
struct KoiosAddressInfo {
/// Total lovelace at this address.
balance: String,
#[serde(default)]
utxo_set: Vec<KoiosUtxo>,
}
#[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<u64>,
}
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<String>) -> 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<String>, timeout: Duration) -> Self {
Self::with_timeout_and_bearer(base_url, timeout, None)
}
/// Construct a client with optional `Authorization: Bearer <token>`
/// 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<String>,
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<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()))
}
/// 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<T: Serialize>(
&self,
path: &str,
body: &T,
) -> Result<String, ChainError> {
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<String, ChainError> {
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<u64, ChainError> {
s.parse::<u64>()
.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<Utxo, ChainError> {
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<Vec<Utxo>, 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<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.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<String, ChainError> {
// /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<TxStatus, ChainError> {
let body = TxHashesBody {
tx_hashes: vec![tx_hash],
};
let raw: Vec<KoiosTxStatusResp> = 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<KoiosUtxo> = serde_json::from_str(SAMPLE).unwrap();
let utxos: Vec<Utxo> = raw
.into_iter()
.map(convert_utxo)
.collect::<Result<_, _>>()
.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<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.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<KoiosTxStatusResp> = 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<KoiosTxStatusResp> = 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<KoiosTxStatusResp> = serde_json::from_str(omitted).unwrap();
assert_eq!(v[0].num_confirmations, None);
let empty = r#"[]"#;
let v: Vec<KoiosTxStatusResp> = 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()
);
}
}