diff --git a/crates/aldabra-chain/src/koios.rs b/crates/aldabra-chain/src/koios.rs index b400844..6bcd4d9 100644 --- a/crates/aldabra-chain/src/koios.rs +++ b/crates/aldabra-chain/src/koios.rs @@ -37,6 +37,20 @@ struct AddressesBody<'a> { addresses: Vec<&'a str>, } +/// Same as [`AddressesBody`] but with the `_extended` flag set. +/// Koios's `/address_utxos` returns `asset_list: null` (or empty) +/// without it; with it, the per-utxo asset bundles come through +/// reliably. Discovered preprod 2026-05-04 — without this flag the +/// wallet sees its own asset-bearing UTXOs as ada-only and refuses +/// to construct a multi-asset send. +#[derive(Serialize)] +struct AddressesExtendedBody<'a> { + #[serde(rename = "_addresses")] + addresses: Vec<&'a str>, + #[serde(rename = "_extended")] + extended: bool, +} + #[derive(Deserialize)] struct KoiosAsset { policy_id: String, @@ -52,8 +66,13 @@ struct KoiosUtxo { 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. Found at integration time on live preprod + /// 2026-05-04 — our hand-crafted test fixtures all used `[]`. #[serde(default)] - asset_list: Vec, + asset_list: Option>, } #[derive(Deserialize)] @@ -147,7 +166,7 @@ fn asset_key(policy_id: &str, asset_name_hex: &str) -> String { 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 { + 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); } @@ -162,7 +181,15 @@ fn convert_utxo(k: KoiosUtxo) -> Result { #[async_trait] impl ChainBackend for KoiosClient { async fn get_utxos(&self, address: &str) -> Result, ChainError> { - let body = AddressesBody { addresses: vec![address] }; + // `_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() } @@ -184,7 +211,7 @@ impl ChainBackend for KoiosClient { 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 { + 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); @@ -204,13 +231,24 @@ impl ChainBackend for KoiosClient { .body(raw_tx_cbor.to_vec()) .send() .await - .map_err(|e| ChainError::Network(e.to_string()))? - .error_for_status() .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()`. Discovered during + // preprod cip-68 mint debugging 2026-05-04: a 400 with no + // surfaced body left us guessing at why the chain rejected the tx. + 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. @@ -297,6 +335,31 @@ mod tests { } ]"#; + /// Real Koios `/address_utxos` returns `asset_list: null` for + /// ada-only utxos (vs `/address_info` which returns `[]`). + /// Regression test — caught at preprod integration time + /// 2026-05-04 after our hand-crafted fixtures all used `[]`. + #[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(); @@ -351,7 +414,7 @@ mod tests { 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 { + 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); diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index 6351d00..a381964 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -298,7 +298,14 @@ pub struct Cip68NftArgs { } fn default_token_lovelace() -> u64 { - 1_500_000 + // 2.5 ADA — Babbage min-utxo for an inline-datum-bearing + // multi-asset output is ~1.79 ADA (depends on datum size). + // 1.5 was too low; 2.5 gives comfortable margin for typical + // CIP-68 metadata (~150 bytes). Larger metadata still requires + // the caller to override. + // Discovered preprod 2026-05-04 via + // BabbageOutputTooSmallUTxO chain rejection. + 2_500_000 } #[derive(Debug, Deserialize, schemars::JsonSchema)]