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.
595 lines
21 KiB
Rust
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()
|
|
);
|
|
}
|
|
}
|